#!/usr/bin/env python

"""
Import a Mantis bug database into Trac

Requires:  Trac 0.8 from http://trac.edgewall.com/
           Python 2.3 from http://www.python.org/
           MySQL >= 3.23 from http://www.mysql.org/

Thanks: Dmitry Yusupov, author of bugzilla2trac on which this
    script is based

Copyright 2005, Blake Watters <blake@near-time.com>
"""

###
### Conversion Settings -- edit these before running if desired
###

# Mantis version.
#
# Currently, the following Mantis versions are known to work:
#   2.11
#
# If you run this script on a version not listed here and it is successful,
# please report it to the Trac mailing list so we can update the list.
MT_VERSION = '0.19.2'

# MySQL connection parameters for the Mantis database.  These can also 
# be specified on the command line.
MT_DB = ''
MT_HOST = 'localhost'
MT_USER = ''
MT_PASSWORD = ''

# Path to the Trac environment.
TRAC_ENV = ''

# If true, all existing Trac tickets and attachments will be removed 
# prior to import.
TRAC_CLEAN = False

# Enclose imported ticket description and comments in a {{{ }}} 
# preformat block?  This formats the text in a fixed-point font.
PREFORMAT_COMMENTS = False

# By default, all bugs are imported from Mantis.  If you add a list
# of products here, only bugs from those projects will be imported.
PROJECTS = [2]

# Trac doesn't have the concept of a project.  Instead, this script can
# assign keywords in the ticket entry to represent projects.
#
# ex. PROJECT_KEYWORDS = { 'project1' : 'PROJECT_KEYWORD' }
PROJECT_KEYWORDS = {}

# Bug comments that should not be imported.  Each entry in list should
# be a regular expression.
IGNORE_COMMENTS = []

###########################################################################
### You probably don't need to change any configuration past this line. ###
###########################################################################

# Mantis status to Trac status translation map.
#
# NOTE: bug activity is translated as well, which may cause bug
# activity to be deleted (e.g. resolved -> closed in Mantis
# would translate into closed -> closed in Trac, so we just ignore the
# change).
#
# There is some special magic for open in the code:  if there is no
# Mantis owner, open is mapped to 'new' instead.
STATUS_TRANSLATE = {
    10 : 'new',
    20 : 'assigned',    
    30 : 'assigned',
    40 : 'assigned',
    50 : 'assigned',
    80 : 'closed',
    90 : 'closed'
}

#
# Mantis resolution to Trac resolution translator
#
# $g_resolution_enum_string                       = '10:open,20:fixed,30:reopened,40:unable to duplicate,50:not fixable,60:duplicate,70:not a bug,80:suspended,90:wont fix';
RESOLUTION_TRANSLATE = {
    10 : '',
    20 : 'fixed',
    30 : '',
    40 : 'worksforme',
    50 : 'wontfix',
    60 : 'duplicate',
    70 : 'invalid',
    80 : '',
    90 : 'wontfix'
}

#
# Mantis severity to Trac severity translation map.
#
SEVERITY_TRANSLATE = {
    '10' : 'enhancement',
    '20' : 'trivial',
    '30' : 'trivial',
    '40' : 'trivial',
    '50' : 'minor',
    '60' : 'major',
    '70' : 'critical',
    '80' : 'blocker'
}

#
# Mantis priority to Trac priority 
#
PRIORITY_TRANSLATE = {
    '10' : 'lowest',
    '20' : 'low',
    '30' : 'normal',
    '40' : 'high',
    '50' : 'highest',
    '60' : 'highest'
}

# Translate Mantis statuses into Trac keywords.  This provides a way 
# to retain the Mantis statuses in Trac.  e.g. when a bug is marked 
# 'verified' in Mantis it will be assigned a VERIFIED keyword.
STATUS_KEYWORDS = {
  'verified' : 'VERIFIED',
  'released' : 'RELEASED'
}

# Some fields in Mantis do not have equivalents in Trac.  Changes in
# fields listed here will not be imported into the ticket change history,
# otherwise you'd see changes for fields that don't exist in Trac.
IGNORED_ACTIVITY_FIELDS = ['everconfirmed']

###
### Script begins here
###

import os
import re
import sys
import string
import StringIO

import MySQLdb
import MySQLdb.cursors
import trac.Environment
import sqlite

if not hasattr(sys, 'setdefaultencoding'):
    reload(sys)

sys.setdefaultencoding('latin1')

# simulated Attachment class for trac.add
class Attachment:
    def __init__(self, name, data):
        self.filename = name
        self.file = StringIO.StringIO(data.tostring())
  
# simple field translation mapping.  if string not in
# mapping, just return string, otherwise return value
class FieldTranslator(dict):
    def __getitem__(self, item):
        if not dict.has_key(self, item):
            return item
            
        return dict.__getitem__(self, item)

statusXlator = FieldTranslator(STATUS_TRANSLATE)
severityXlator = FieldTranslator(SEVERITY_TRANSLATE)
priorityXlator = FieldTranslator(PRIORITY_TRANSLATE)
resolutionXlator = FieldTranslator(RESOLUTION_TRANSLATE)

class TracDatabase(object):
    def __init__(self, path):
        self.env = trac.Environment.Environment(path)
        self._db = self.env.get_db_cnx()
        self._db.autocommit = False
        self.mysql_cur = False
        self.loginNameCache = {}
        self.fieldNameCache = {}
    
    def db(self):
        return self._db
    
    def hasTickets(self):
        c = self.db().cursor()
        c.execute('''SELECT count(*) FROM Ticket''')
        return int(c.fetchall()[0][0]) > 0

    def assertNoTickets(self):
        if self.hasTickets():
            raise Exception("Will not modify database with existing tickets!")
    
    def setSeverityList(self, s):
        """Remove all severities, set them to `s`"""
        self.assertNoTickets()
        
        c = self.db().cursor()
        c.execute("""DELETE FROM enum WHERE type='severity'""")
        for value, i in s:
            print "inserting severity ", value, " ", i
            c.execute("""INSERT INTO enum (type, name, value) VALUES (%s, %s, %s)""",
                      "severity", value.encode('utf-8'), i)
        self.db().commit()
    
    def setPriorityList(self, s):
        """Remove all priorities, set them to `s`"""
        self.assertNoTickets()
        
        c = self.db().cursor()
        c.execute("""DELETE FROM enum WHERE type='priority'""")
        for value, i in s:
            print "inserting priority ", value, " ", i
            c.execute("""INSERT INTO enum (type, name, value) VALUES (%s, %s, %s)""",
                      "priority",
                      value.encode('utf-8'),
                      i)
        self.db().commit()

    
    def setComponentList(self, l, key):
        """Remove all components, set them to `l`"""
        self.assertNoTickets()
        
        c = self.db().cursor()
        c.execute("""DELETE FROM component""")
        for comp in l:
            name = comp[key].strip()
            print "inserting component '",name,"', owner",  comp['owner']
            c.execute("""INSERT INTO component (name, owner) VALUES (%s, %s)""",
                      name.encode('utf-8'), comp['owner'].encode('utf-8'))
        self.db().commit()
    
    def setVersionList(self, v, key):
        """Remove all versions, set them to `v`"""
        self.assertNoTickets()
        
        c = self.db().cursor()
        c.execute("""DELETE FROM version""")
        for vers in v:
            print "inserting version ", vers[key]
            c.execute("""INSERT INTO version (name) VALUES (%s)""",
                      vers[key].encode('utf-8'))
        self.db().commit()
        
    def setMilestoneList(self, m, key):
        """Remove all milestones, set them to `m`"""
        self.assertNoTickets()
        
        c = self.db().cursor()
        c.execute("""DELETE FROM milestone""")
        for ms in m:
            print "inserting milestone ", ms[key]
            c.execute("""INSERT INTO milestone (name) VALUES (%s)""",
                      ms[key].encode('utf-8'))
        self.db().commit()
    
    def addTicket(self, id, time, changetime, component,
                  severity, priority, owner, reporter, cc,
                  version, milestone, status, resolution,
                  summary, description, keywords):
        c = self.db().cursor()
        
        desc = description.encode('utf-8')
        
        if PREFORMAT_COMMENTS:
          desc = '{{{\n%s\n}}}' % desc

        print "inserting ticket %s -- %s" % (id, summary)
        c.execute("""INSERT INTO ticket (id, time, changetime, component,
                                         severity, priority, owner, reporter, cc,
                                         version, milestone, status, resolution,
                                         summary, description, keywords)
                                 VALUES (%s, %s, %s, %s,
                                         %s, %s, %s, %s, %s,
                                         %s, %s, %s, %s,
                                         %s, %s, %s)""",
                  id, time.strftime('%s'), changetime.strftime('%s'), component.encode('utf-8'),
                  severity.encode('utf-8'), priority.encode('utf-8'), owner, reporter, cc,
                  version, milestone.encode('utf-8'), status.lower(), resolution,
                  summary.encode('utf-8'), desc, keywords)
        
        self.db().commit()
        return self.db().db.sqlite_last_insert_rowid()
    
    def addTicketComment(self, ticket, time, author, value):
        comment = value.encode('utf-8')
        
        if PREFORMAT_COMMENTS:
          comment = '{{{\n%s\n}}}' % comment

        c = self.db().cursor()
        c.execute("""INSERT INTO ticket_change (ticket, time, author, field, oldvalue, newvalue)
                                 VALUES        (%s, %s, %s, %s, %s, %s)""",
                  ticket, time.strftime('%s'), author, 'comment', '', comment)
        self.db().commit()

    def addTicketChange(self, ticket, time, author, field, oldvalue, newvalue):
        c = self.db().cursor()
        
        if field == 'status':
            oldvalue = statusXlator[int(oldvalue)]
            newvalue = statusXlator[int(newvalue)]
        
        if field == 'severity':
            oldvalue = severityXlator[oldvalue]
            newvalue = severityXlator[newvalue]
        
        if field == 'owner':
            if oldvalue == 0:
                oldvalue = 'unassigned'
            else:
                oldvalue = self.getLoginName(self.mysql_cur, oldvalue)
                
            if newvalue == 0:
                newvalue = 'unassigned'
            else:
                newvalue = self.getLoginName(self.mysql_cur, newvalue)
        
        if field == 'resolution':
            oldvalue = resolutionXlator[oldvalue]
            newvalue = resolutionXlator[newvalue]
        
        if field == 'priority':
            oldvalue = priorityXlator[oldvalue]
            newvalue = priorityXlator[newvalue]            
            
        try:
            c.execute("""INSERT INTO ticket_change (ticket, time, author, field, oldvalue, newvalue)
                                 VALUES        (%s, %s, %s, %s, %s, %s)""",
                  ticket, time.strftime('%s'), author, field, oldvalue.encode('utf-8'), newvalue.encode('utf-8'))
            self.db().commit()
        
        except sqlite.IntegrityError, excp:
            print 'Warning : Ticket ID ' + str(ticket) + ' ' + str(excp)
            
        
    def addAttachment(self, id, attachment, description, author):
        print 'inserting attachment for ticket %s -- %s' % (id, description)
        attachment.filename = attachment.filename.encode('utf-8')
        try:
            self.env.create_attachment(self.db(), 'ticket', str(id), attachment, description.encode('utf-8'),
            author, 'unknown')
        
        except trac.util.TracError, excp:
            print 'Warning : Ticket ID ' + str(id) + ' ' + str(excp)
        
    def getLoginName(self, cursor, userid):
        if userid not in self.loginNameCache:
            cursor.execute("SELECT * FROM mantis_user_table WHERE id = %s" % userid)
            loginName = cursor.fetchall()

            if loginName:
                loginName = loginName[0]['username']
            else:
                #print 'warning: unknown Mantis userid %i, recording as anonymous' % userid
                loginName = 'anonymous'

            self.loginNameCache[userid] = loginName

        return self.loginNameCache[userid]
    
    def setMysqlCursor(self, cursor):
        self.mysql_cur = cursor
        
    def getFieldName(self, cursor, fieldid):
        if fieldid not in self.fieldNameCache:
            cursor.execute("SELECT * FROM fielddefs WHERE fieldid = %s" % fieldid)
            fieldName = cursor.fetchall()

            if fieldName:
                fieldName = fieldName[0]['name'].lower()
            else:
                print 'warning: unknown Mantis fieldid %d, recording as unknown' % userid
                fieldName = 'unknown'

            self.fieldNameCache[fieldid] = fieldName

        return self.fieldNameCache[fieldid]

def projectFilter(fieldName, projects):
    first = True
    result = ''
    for project in projects:
        if not first: 
            result += " or "
        first = False
        result += "%s = '%s'" % (fieldName, project)
    return result

def convert(_db, _host, _user, _password, _env, _force):
    # init Mantis environment
    print "Mantis MySQL('%s':'%s':'%s':'%s'): connecting..." % (_db, _host, _user, _password)
    mysql_con = MySQLdb.connect(host=_host, 
                user=_user, passwd=_password, db=_db, compress=1, 
                cursorclass=MySQLdb.cursors.DictCursor)
    mysql_cur = mysql_con.cursor()

    # init Trac environment
    print "Trac SQLite('%s'): connecting..." % (_env)
    trac = TracDatabase(_env)
    trac.setMysqlCursor(mysql_cur)
    
    # force mode...
    if _force == 1:
        print "cleaning all tickets..."
        c = trac.db().cursor()
        c.execute("""DELETE FROM ticket_change""")
        trac.db().commit()
        c.execute("""DELETE FROM ticket""")
        trac.db().commit()
        c.execute("""DELETE FROM attachment""")
        os.system('rm -rf %s' % trac.env.get_attachments_dir())
        os.mkdir(trac.env.get_attachments_dir())
        trac.db().commit()


    print
    print "1. import severities..."
    severities = (('blocker', '1'), ('critical', '2'), ('major', '3'), ('normal', '4'),
        ('minor', '5'), ('trivial', '6'), ('enhancement', '7'))
    trac.setSeverityList(severities)

    print
    print "2. import components..."
    sql = "SELECT category, user_id AS owner FROM mantis_project_category_table"
    if PROJECTS:
       sql += " WHERE %s" % projectFilter('project_id', PROJECTS)
    mysql_cur.execute(sql)
    components = mysql_cur.fetchall()
    for component in components:
    		component['owner'] = trac.getLoginName(mysql_cur, component['owner'])
    trac.setComponentList(components, 'category')

    print
    print "3. import priorities..."
    priorities = (('lowest', '1'), ('low', '2'), ('normal', '3'), ('high', '4'), ('highest', '5'))
    trac.setPriorityList(priorities)

    print
    print "4. import versions..."
    sql = "SELECT DISTINCTROW version FROM mantis_project_version_table"
    if PROJECTS:
       sql += " WHERE %s" % projectFilter('project_id', PROJECTS)
    mysql_cur.execute(sql)
    versions = mysql_cur.fetchall()
    trac.setVersionList(versions, 'version')

    print
    print '5. retrieving bugs...'
    sql = "SELECT * FROM mantis_bug_table "
    if PROJECTS:
       sql += " WHERE %s" % projectFilter('project_id', PROJECTS)
    sql += " ORDER BY id"
    mysql_cur.execute(sql)
    bugs = mysql_cur.fetchall()
    
    print
    print "6. import bugs and bug activity..."
    for bug in bugs:
        bugid = bug['id']
        
        ticket = {}
        keywords = []
        ticket['id'] = bugid
        ticket['time'] = bug['date_submitted']
        ticket['changetime'] = bug['last_updated']
        ticket['component'] = bug['category']
        
        severity = str(bug['severity'])
        ticket['severity'] = severityXlator[severity]
        
        priority = str(bug['priority'])
        ticket['priority'] = priorityXlator[priority]

        ticket['owner'] = trac.getLoginName(mysql_cur, bug['handler_id'])
        ticket['reporter'] = trac.getLoginName(mysql_cur, bug['reporter_id'])

        mysql_cur.execute("SELECT * FROM mantis_bug_monitor_table WHERE bug_id = %s" % bugid)
        cc_records = mysql_cur.fetchall()
        cc_list = []
        for cc in cc_records:
            cc_list.append(trac.getLoginName(mysql_cur, cc['user_id']))
        ticket['cc'] = string.join(cc_list, ', ')

        ticket['version'] = bug['version']
        ticket['milestone'] = ''

        bug_status = bug['status']
        ticket['status'] = statusXlator[bug_status]
        ticket['resolution'] = resolutionXlator[bug['resolution']]

        # a bit of extra work to do open tickets
        if bug_status == 'open':
            if owner != '':
                ticket['status'] = 'assigned'
            else:
                ticket['status'] = 'new'

        ticket['summary'] = bug['summary']

        # TODO: Figure out keywords
        #keywords = string.split(bug['keywords'], ' ')
        keywords = ''

        mysql_cur.execute("SELECT * FROM mantis_bug_text_table WHERE id = %s" % bugid) 
        texts = mysql_cur.fetchone()
        ticket['description'] = texts['description']
        
        # Add the additional info and steps to reproduce as comments
        trac.addTicketComment(bugid,
            bug['date_submitted'],
            trac.getLoginName(mysql_cur, bug['reporter_id']),
            texts['steps_to_reproduce'])
        
#        trac.addTicketComment(bugid,
#            bug['date_submitted'],
#            trac.getLoginName(mysql_cur, bug['reporter_id']),
#            texts['additional_information'])

        # Grab the bug notes as comments
        mysql_cur.execute("SELECT * FROM mantis_bugnote_table WHERE bug_id = %s" % bugid)
        bugnotes = mysql_cur.fetchall()
        
        for desc in bugnotes:            
            ignore = False
            mysql_cur.execute("SELECT * FROM mantis_bugnote_text_table WHERE id = %s" % desc['id'])
            text = mysql_cur.fetchone()
            try:
                note = str(text['note'])
            except TypeError:
                continue
                
            for comment in IGNORE_COMMENTS:
                if re.match(comment, note):
                    ignore = True
                    
            if ignore: continue
            
            trac.addTicketComment(bugid,
                desc['date_submitted'],
                trac.getLoginName(mysql_cur, desc['reporter_id']),
                note)

        # Import the bug history
        mysql_cur.execute("SELECT * FROM mantis_bug_history_table WHERE bug_id = %s ORDER BY date_modified" % bugid)
        bugs_activity = mysql_cur.fetchall()
        resolution = ''
        ticketChanges = []
        for activity in bugs_activity:
            field_name = activity['field_name']            
            removed = activity['old_value']
            added = activity['new_value']

            # remember most recent resolution, we need this later
            if field_name == 'resolution':
                resolution = resolutionXlator[added.lower()]

            keywordChange = False
            oldKeywords = string.join(keywords, " ")

            # convert Mantis field names...
            if field_name == 'handler_id':
                field_name = 'owner'
            elif field_name == 'status':
                if removed in STATUS_KEYWORDS:
                    kw = STATUS_KEYWORDS[removed]
                    if kw in keywords:
                        keywords.remove(kw)
                    else:
                        oldKeywords = string.join(keywords + [ kw ], " ")
                    keywordChange = True
                if added in STATUS_KEYWORDS:
                    kw = STATUS_KEYWORDS[added]
                    keywords.append(kw)
                    keywordChange = True
                added = statusXlator[added]
                removed = statusXlator[removed]
            elif field_name == 'project_id':
                if removed in PROJECT_KEYWORDS:
                    kw = PROJECT_KEYWORDS[removed]
                    if kw in keywords:
                        keywords.remove(kw)
                    else:
                        oldKeywords = string.join(keywords + [ kw ], " ")
                    keywordChange = True
                if added in PROJECT_KEYWORDS:
                    kw = PROJECT_KEYWORDS[added]
                    keywords.append(kw)
                    keywordChange = True

            ticketChange = {}
            ticketChange['ticket'] = bugid
            ticketChange['time'] = activity['date_modified']
            ticketChange['author'] = trac.getLoginName(mysql_cur, activity['user_id'])
            ticketChange['field'] = field_name
            #ticketChange['oldvalue'] = removed
            ticketChange['oldvalue'] = activity['old_value']
            #ticketChange['newvalue'] = added
            ticketChange['newvalue'] = activity['new_value']

            if keywordChange:
                newKeywords = string.join(keywords, " ")
                ticketChangeKw = ticketChange
                ticketChangeKw['field'] = 'keywords'
                ticketChangeKw['oldvalue'] = oldKeywords
                ticketChangeKw['newvalue'] = newKeywords
                #trac.addTicketChange(ticket=bugid, time=activity['bug_when'],
                #    author=trac.getLoginName(mysql_cur, activity['who']),
                #    field='keywords', oldvalue=oldKeywords, newvalue=newKeywords)
                ticketChanges.append(ticketChangeKw)

            if field_name in IGNORED_ACTIVITY_FIELDS:
                continue

            # skip changes that have no effect (think translation!)
            if added == removed:
                continue
                
            # Mantis splits large summary changes into two records
            for oldChange in ticketChanges:
              if (field_name == 'summary'
                  and oldChange['field'] == ticketChange['field'] 
                  and oldChange['time'] == ticketChange['time'] 
                  and oldChange['author'] == ticketChange['author']):
                  oldChange['oldvalue'] += " " + ticketChange['oldvalue'] 
                  oldChange['newvalue'] += " " + ticketChange['newvalue']
                  break
            else:
                #trac.addTicketChange(ticket=bugid, time=activity['bug_when'],
                #    author=trac.getLoginName(mysql_cur, activity['who']),
                #    field=field_name, oldvalue=removed, newvalue=added)
                ticketChanges.append (ticketChange)

        for ticketChange in ticketChanges:
            trac.addTicketChange (**ticketChange)

        if bug['status'] in STATUS_KEYWORDS:
            kw = STATUS_KEYWORDS[bug['status']]
            # may have already been added during activity import
            if kw not in keywords:
                keywords.append(kw)

        if bug['project_id'] in PROJECT_KEYWORDS:
            kw = PROJECT_KEYWORDS[bug['project_id']]
            # may have already been added during activity import
            if kw not in keywords:
                keywords.append(kw)

        mysql_cur.execute("SELECT * FROM mantis_bug_file_table WHERE bug_id = %s" % bugid)
        attachments = mysql_cur.fetchall()
        for a in attachments:
            author = trac.getLoginName(mysql_cur, bug['reporter_id'])
            
            tracAttachment = Attachment(a['filename'], a['content'])
            trac.addAttachment(bugid, tracAttachment, a['description'], author)
            
        ticket['keywords'] = string.join(keywords)                
        ticketid = trac.addTicket(**ticket)

    print "Success!"

def usage():
    print "Mantis2trac - Imports a bug database from Mantis into Trac."
    print
    print "Usage: mantis2trac.py [options]"
    print
    print "Available Options:"
    print "  --db <MySQL dbname>              - Mantis's database"
    print "  --tracenv /path/to/trac/env      - full path to Trac db environment"
    print "  -h | --host <MySQL hostname>     - Mantis's DNS host name"
    print "  -u | --user <MySQL username>     - effective Mantis's database user"
    print "  -p | --passwd <MySQL password>   - Mantis's user password"
    print "  -c | --clean                     - remove current Trac tickets before importing"
    print "  --help | help                    - this help info"
    print
    print "Additional configuration options can be defined directly in the script."
    print
    sys.exit(0)

def main():
    global MT_DB, MT_HOST, MT_USER, MT_PASSWORD, TRAC_ENV, TRAC_CLEAN
    if len (sys.argv) > 1:
        if sys.argv[1] in ['--help','help'] or len(sys.argv) < 4:
            usage()
        iter = 1
        while iter < len(sys.argv):
            if sys.argv[iter] in ['--db'] and iter+1 < len(sys.argv):
                MT_DB = sys.argv[iter+1]
                iter = iter + 1
            elif sys.argv[iter] in ['-h', '--host'] and iter+1 < len(sys.argv):
                MT_HOST = sys.argv[iter+1]
                iter = iter + 1
            elif sys.argv[iter] in ['-u', '--user'] and iter+1 < len(sys.argv):
                MT_USER = sys.argv[iter+1]
                iter = iter + 1
            elif sys.argv[iter] in ['-p', '--passwd'] and iter+1 < len(sys.argv):
                MT_PASSWORD = sys.argv[iter+1]
                iter = iter + 1
            elif sys.argv[iter] in ['--tracenv'] and iter+1 < len(sys.argv):
                TRAC_ENV = sys.argv[iter+1]
                iter = iter + 1
            elif sys.argv[iter] in ['-c', '--clean']:
                TRAC_CLEAN = 1
            else:
                print "Error: unknown parameter: " + sys.argv[iter]
                sys.exit(0)
            iter = iter + 1
    else:
        usage()
        
    convert(MT_DB, MT_HOST, MT_USER, MT_PASSWORD, TRAC_ENV, TRAC_CLEAN)

if __name__ == '__main__':
    main()

