hgext/bugzilla.py
changeset 2192 2be3ac7abc21
child 2197 5de8b44f0446
child 2218 afe24f5b7a9e
equal deleted inserted replaced
2191:c2e43535d4d1 2192:2be3ac7abc21
       
     1 # bugzilla.py - bugzilla integration for mercurial
       
     2 #
       
     3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
       
     4 #
       
     5 # This software may be used and distributed according to the terms
       
     6 # of the GNU General Public License, incorporated herein by reference.
       
     7 #
       
     8 # hook extension to update comments of bugzilla bugs when changesets
       
     9 # that refer to bugs by id are seen.  this hook does not change bug
       
    10 # status, only comments.
       
    11 #
       
    12 # to configure, add items to '[bugzilla]' section of hgrc.
       
    13 #
       
    14 # to use, configure bugzilla extension and enable like this:
       
    15 #
       
    16 #   [extensions]
       
    17 #   hgext.bugzilla =
       
    18 #
       
    19 #   [hooks]
       
    20 #   # run bugzilla hook on every change pulled or pushed in here
       
    21 #   incoming.bugzilla = python:hgext.bugzilla.hook
       
    22 #
       
    23 # config items:
       
    24 #
       
    25 # REQUIRED:
       
    26 #   host = bugzilla # mysql server where bugzilla database lives
       
    27 #   password = **   # user's password
       
    28 #   version = 2.16  # version of bugzilla installed
       
    29 #
       
    30 # OPTIONAL:
       
    31 #   bzuser = ...    # bugzilla user id to record comments with
       
    32 #   db = bugs       # database to connect to
       
    33 #   hgweb = http:// # root of hg web site for browsing commits
       
    34 #   notify = ...    # command to run to get bugzilla to send mail
       
    35 #   regexp = ...    # regexp to match bug ids (must contain one "()" group)
       
    36 #   strip = 0       # number of slashes to strip for url paths
       
    37 #   style = ...     # style file to use when formatting comments
       
    38 #   template = ...  # template to use when formatting comments
       
    39 #   timeout = 5     # database connection timeout (seconds)
       
    40 #   user = bugs     # user to connect to database as
       
    41 
       
    42 from mercurial.demandload import *
       
    43 from mercurial.i18n import gettext as _
       
    44 from mercurial.node import *
       
    45 demandload(globals(), 'cStringIO mercurial:templater,util os re time')
       
    46 
       
    47 try:
       
    48     import MySQLdb
       
    49 except ImportError:
       
    50     raise util.Abort(_('python mysql support not available'))
       
    51 
       
    52 def buglist(ids):
       
    53     return '(' + ','.join(map(str, ids)) + ')'
       
    54 
       
    55 class bugzilla_2_16(object):
       
    56     '''support for bugzilla version 2.16.'''
       
    57 
       
    58     def __init__(self, ui):
       
    59         self.ui = ui
       
    60         host = self.ui.config('bugzilla', 'host', 'localhost')
       
    61         user = self.ui.config('bugzilla', 'user', 'bugs')
       
    62         passwd = self.ui.config('bugzilla', 'password')
       
    63         db = self.ui.config('bugzilla', 'db', 'bugs')
       
    64         timeout = int(self.ui.config('bugzilla', 'timeout', 5))
       
    65         self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
       
    66                      (host, db, user, '*' * len(passwd)))
       
    67         self.conn = MySQLdb.connect(host=host, user=user, passwd=passwd,
       
    68                                     db=db, connect_timeout=timeout)
       
    69         self.cursor = self.conn.cursor()
       
    70         self.run('select fieldid from fielddefs where name = "longdesc"')
       
    71         ids = self.cursor.fetchall()
       
    72         if len(ids) != 1:
       
    73             raise util.Abort(_('unknown database schema'))
       
    74         self.longdesc_id = ids[0][0]
       
    75         self.user_ids = {}
       
    76 
       
    77     def run(self, *args, **kwargs):
       
    78         '''run a query.'''
       
    79         self.ui.note(_('query: %s %s\n') % (args, kwargs))
       
    80         try:
       
    81             self.cursor.execute(*args, **kwargs)
       
    82         except MySQLdb.MySQLError, err:
       
    83             self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
       
    84             raise
       
    85 
       
    86     def filter_real_bug_ids(self, ids):
       
    87         '''filter not-existing bug ids from list.'''
       
    88         self.run('select bug_id from bugs where bug_id in %s' % buglist(ids))
       
    89         ids = [c[0] for c in self.cursor.fetchall()]
       
    90         ids.sort()
       
    91         return ids
       
    92 
       
    93     def filter_unknown_bug_ids(self, node, ids):
       
    94         '''filter bug ids from list that already refer to this changeset.'''
       
    95 
       
    96         self.run('''select bug_id from longdescs where
       
    97                     bug_id in %s and thetext like "%%%s%%"''' %
       
    98                  (buglist(ids), short(node)))
       
    99         unknown = dict.fromkeys(ids)
       
   100         for (id,) in self.cursor.fetchall():
       
   101             self.ui.status(_('bug %d already knows about changeset %s\n') %
       
   102                            (id, short(node)))
       
   103             unknown.pop(id, None)
       
   104         ids = unknown.keys()
       
   105         ids.sort()
       
   106         return ids
       
   107 
       
   108     def notify(self, ids):
       
   109         '''tell bugzilla to send mail.'''
       
   110 
       
   111         self.ui.status(_('telling bugzilla to send mail:\n'))
       
   112         for id in ids:
       
   113             self.ui.status(_('  bug %s\n') % id)
       
   114             cmd = self.ui.config('bugzilla', 'notify',
       
   115                                'cd /var/www/html/bugzilla && '
       
   116                                './processmail %s nobody@nowhere.com') % id
       
   117             fp = os.popen('(%s) 2>&1' % cmd)
       
   118             out = fp.read()
       
   119             ret = fp.close()
       
   120             if ret:
       
   121                 self.ui.warn(out)
       
   122                 raise util.Abort(_('bugzilla notify command %s') %
       
   123                                  util.explain_exit(ret)[0])
       
   124         self.ui.status(_('done\n'))
       
   125 
       
   126     def get_user_id(self, user):
       
   127         '''look up numeric bugzilla user id.'''
       
   128         try:
       
   129             return self.user_ids[user]
       
   130         except KeyError:
       
   131             try:
       
   132                 userid = int(user)
       
   133             except ValueError:
       
   134                 self.ui.note(_('looking up user %s\n') % user)
       
   135                 self.run('''select userid from profiles
       
   136                             where login_name like %s''', user)
       
   137                 all = self.cursor.fetchall()
       
   138                 if len(all) != 1:
       
   139                     raise KeyError(user)
       
   140                 userid = int(all[0][0])
       
   141             self.user_ids[user] = userid
       
   142             return userid
       
   143 
       
   144     def add_comment(self, bugid, text, prefuser):
       
   145         '''add comment to bug. try adding comment as committer of
       
   146         changeset, otherwise as default bugzilla user.'''
       
   147         try:
       
   148             userid = self.get_user_id(prefuser)
       
   149         except KeyError:
       
   150             try:
       
   151                 defaultuser = self.ui.config('bugzilla', 'bzuser')
       
   152                 userid = self.get_user_id(defaultuser)
       
   153             except KeyError:
       
   154                 raise util.Abort(_('cannot find user id for %s or %s') %
       
   155                                  (prefuser, defaultuser))
       
   156         now = time.strftime('%Y-%m-%d %H:%M:%S')
       
   157         self.run('''insert into longdescs
       
   158                     (bug_id, who, bug_when, thetext)
       
   159                     values (%s, %s, %s, %s)''',
       
   160                  (bugid, userid, now, text))
       
   161         self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
       
   162                     values (%s, %s, %s, %s)''',
       
   163                  (bugid, userid, now, self.longdesc_id))
       
   164 
       
   165 class bugzilla(object):
       
   166     # supported versions of bugzilla. different versions have
       
   167     # different schemas.
       
   168     _versions = {
       
   169         '2.16': bugzilla_2_16,
       
   170         }
       
   171 
       
   172     _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
       
   173                        r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')
       
   174 
       
   175     _bz = None
       
   176 
       
   177     def __init__(self, ui, repo):
       
   178         self.ui = ui
       
   179         self.repo = repo
       
   180 
       
   181     def bz(self):
       
   182         '''return object that knows how to talk to bugzilla version in
       
   183         use.'''
       
   184 
       
   185         if bugzilla._bz is None:
       
   186             bzversion = self.ui.config('bugzilla', 'version')
       
   187             try:
       
   188                 bzclass = bugzilla._versions[bzversion]
       
   189             except KeyError:
       
   190                 raise util.Abort(_('bugzilla version %s not supported') %
       
   191                                  bzversion)
       
   192             bugzilla._bz = bzclass(self.ui)
       
   193         return bugzilla._bz
       
   194 
       
   195     def __getattr__(self, key):
       
   196         return getattr(self.bz(), key)
       
   197 
       
   198     _bug_re = None
       
   199     _split_re = None
       
   200 
       
   201     def find_bug_ids(self, node, desc):
       
   202         '''find valid bug ids that are referred to in changeset
       
   203         comments and that do not already have references to this
       
   204         changeset.'''
       
   205 
       
   206         if bugzilla._bug_re is None:
       
   207             bugzilla._bug_re = re.compile(
       
   208                 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
       
   209                 re.IGNORECASE)
       
   210             bugzilla._split_re = re.compile(r'\D+')
       
   211         start = 0
       
   212         ids = {}
       
   213         while True:
       
   214             m = bugzilla._bug_re.search(desc, start)
       
   215             if not m:
       
   216                 break
       
   217             start = m.end()
       
   218             for id in bugzilla._split_re.split(m.group(1)):
       
   219                 ids[int(id)] = 1
       
   220         ids = ids.keys()
       
   221         if ids:
       
   222             ids = self.filter_real_bug_ids(ids)
       
   223         if ids:
       
   224             ids = self.filter_unknown_bug_ids(node, ids)
       
   225         return ids
       
   226 
       
   227     def update(self, bugid, node, changes):
       
   228         '''update bugzilla bug with reference to changeset.'''
       
   229 
       
   230         def webroot(root):
       
   231             '''strip leading prefix of repo root and turn into
       
   232             url-safe path.'''
       
   233             count = int(self.ui.config('bugzilla', 'strip', 0))
       
   234             root = util.pconvert(root)
       
   235             while count > 0:
       
   236                 c = root.find('/')
       
   237                 if c == -1:
       
   238                     break
       
   239                 root = root[c+1:]
       
   240                 count -= 1
       
   241             return root
       
   242 
       
   243         class stringio(object):
       
   244             '''wrap cStringIO.'''
       
   245             def __init__(self):
       
   246                 self.fp = cStringIO.StringIO()
       
   247 
       
   248             def write(self, *args):
       
   249                 for a in args:
       
   250                     self.fp.write(a)
       
   251 
       
   252             write_header = write
       
   253 
       
   254             def getvalue(self):
       
   255                 return self.fp.getvalue()
       
   256 
       
   257         mapfile = self.ui.config('bugzilla', 'style')
       
   258         tmpl = self.ui.config('bugzilla', 'template')
       
   259         sio = stringio()
       
   260         t = templater.changeset_templater(self.ui, self.repo, mapfile, sio)
       
   261         if not mapfile and not tmpl:
       
   262             tmpl = _('changeset {node|short} in repo {root} refers '
       
   263                      'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
       
   264         if tmpl:
       
   265             tmpl = templater.parsestring(tmpl, quoted=False)
       
   266             t.use_template(tmpl)
       
   267         t.show(changenode=node, changes=changes,
       
   268                bug=str(bugid),
       
   269                hgweb=self.ui.config('bugzilla', 'hgweb'),
       
   270                root=self.repo.root,
       
   271                webroot=webroot(self.repo.root))
       
   272         self.add_comment(bugid, sio.getvalue(), templater.email(changes[1]))
       
   273 
       
   274 def hook(ui, repo, hooktype, node=None, **kwargs):
       
   275     '''add comment to bugzilla for each changeset that refers to a
       
   276     bugzilla bug id. only add a comment once per bug, so same change
       
   277     seen multiple times does not fill bug with duplicate data.'''
       
   278     if node is None:
       
   279         raise util.Abort(_('hook type %s does not pass a changeset id') %
       
   280                          hooktype)
       
   281     try:
       
   282         bz = bugzilla(ui, repo)
       
   283         bin_node = bin(node)
       
   284         changes = repo.changelog.read(bin_node)
       
   285         ids = bz.find_bug_ids(bin_node, changes[4])
       
   286         if ids:
       
   287             for id in ids:
       
   288                 bz.update(id, bin_node, changes)
       
   289             bz.notify(ids)
       
   290         return True
       
   291     except MySQLdb.MySQLError, err:
       
   292         raise util.Abort(_('database error: %s') % err[1])
       
   293