# bugzilla.py - bugzilla integration for mercurial
#
# Copyright 2006 Vadim Gelfer <[email protected]>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2, incorporated herein by reference.

'''hooks for integrating with the Bugzilla bug tracker

This hook extension adds comments on bugs in Bugzilla when changesets
that refer to bugs by Bugzilla ID are seen. The hook does not change
bug status.

The hook updates the Bugzilla database directly. Only Bugzilla
installations using MySQL are supported.

The hook relies on a Bugzilla script to send bug change notification
emails. That script changes between Bugzilla versions; the
'processmail' script used prior to 2.18 is replaced in 2.18 and
subsequent versions by 'config/sendbugmail.pl'. Note that these will
be run by Mercurial as the user pushing the change; you will need to
ensure the Bugzilla install file permissions are set appropriately.

The extension is configured through three different configuration
sections. These keys are recognized in the [bugzilla] section:

host
 Hostname of the MySQL server holding the Bugzilla database.

db
 Name of the Bugzilla database in MySQL. Default 'bugs'.

user
 Username to use to access MySQL server. Default 'bugs'.

password
 Password to use to access MySQL server.

timeout
 Database connection timeout (seconds). Default 5.

version
 Bugzilla version. Specify '3.0' for Bugzilla versions 3.0 and later,
 '2.18' for Bugzilla versions from 2.18 and '2.16' for versions prior
 to 2.18.

bzuser
 Fallback Bugzilla user name to record comments with, if changeset
 committer cannot be found as a Bugzilla user.

bzdir
  Bugzilla install directory. Used by default notify. Default
  '/var/www/html/bugzilla'.

notify
 The command to run to get Bugzilla to send bug change notification
 emails. Substitutes from a map with 3 keys, 'bzdir', 'id' (bug id)
 and 'user' (committer bugzilla email). Default depends on version;
 from 2.18 it is "cd %(bzdir)s && perl -T contrib/sendbugmail.pl
 %(id)s %(user)s".

regexp
 Regular expression to match bug IDs in changeset commit message.
 Must contain one "()" group. The default expression matches 'Bug
 1234', 'Bug no. 1234', 'Bug number 1234', 'Bugs 1234,5678', 'Bug
 1234 and 5678' and variations thereof. Matching is case insensitive.

style
 The style file to use when formatting comments.

template
 Template to use when formatting comments. Overrides style if
 specified. In addition to the usual Mercurial keywords, the
 extension specifies::

   {bug}       The Bugzilla bug ID.
   {root}      The full pathname of the Mercurial repository.
   {webroot}   Stripped pathname of the Mercurial repository.
   {hgweb}     Base URL for browsing Mercurial repositories.

 Default 'changeset {node|short} in repo {root} refers '
         'to bug {bug}.\\ndetails:\\n\\t{desc|tabindent}'

strip
 The number of slashes to strip from the front of {root} to produce
 {webroot}. Default 0.

usermap
 Path of file containing Mercurial committer ID to Bugzilla user ID
 mappings. If specified, the file should contain one mapping per
 line, "committer"="Bugzilla user". See also the [usermap] section.

The [usermap] section is used to specify mappings of Mercurial
committer ID to Bugzilla user ID. See also [bugzilla].usermap.
"committer"="Bugzilla user"

Finally, the [web] section supports one entry:

baseurl
 Base URL for browsing Mercurial repositories. Reference from
 templates as {hgweb}.

Activating the extension::

   [extensions]
   hgext.bugzilla =

   [hooks]
   # run bugzilla hook on every change pulled or pushed in here
   incoming.bugzilla = python:hgext.bugzilla.hook

Example configuration:

This example configuration is for a collection of Mercurial
repositories in /var/local/hg/repos/ used with a local Bugzilla 3.2
installation in /opt/bugzilla-3.2. ::

   [bugzilla]
   host=localhost
   password=XYZZY
   version=3.0
   [email protected]
   bzdir=/opt/bugzilla-3.2
   template=Changeset {node|short} in {root|basename}.
            {hgweb}/{webroot}/rev/{node|short}\\n
            {desc}\\n
   strip=5

   [web]
   baseurl=http://dev.domain.com/hg

   [usermap]
   [email protected][email protected]

Commits add a comment to the Bugzilla bug record of the form::

   Changeset 3b16791d6642 in repository-name.
   http://dev.domain.com/hg/repository-name/rev/3b16791d6642

   Changeset commit comment. Bug 1234.
'''

from mercurial.i18n import _
from mercurial.node import short
from mercurial import cmdutil, templater, util
import re, time

MySQLdb = None

def buglist(ids):
   return '(' + ','.join(map(str, ids)) + ')'

class bugzilla_2_16(object):
   '''support for bugzilla version 2.16.'''

   def __init__(self, ui):
       self.ui = ui
       host = self.ui.config('bugzilla', 'host', 'localhost')
       user = self.ui.config('bugzilla', 'user', 'bugs')
       passwd = self.ui.config('bugzilla', 'password')
       db = self.ui.config('bugzilla', 'db', 'bugs')
       timeout = int(self.ui.config('bugzilla', 'timeout', 5))
       usermap = self.ui.config('bugzilla', 'usermap')
       if usermap:
           self.ui.readconfig(usermap, sections=['usermap'])
       self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
                    (host, db, user, '*' * len(passwd)))
       self.conn = MySQLdb.connect(host=host, user=user, passwd=passwd,
                                   db=db, connect_timeout=timeout)
       self.cursor = self.conn.cursor()
       self.longdesc_id = self.get_longdesc_id()
       self.user_ids = {}
       self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"

   def run(self, *args, **kwargs):
       '''run a query.'''
       self.ui.note(_('query: %s %s\n') % (args, kwargs))
       try:
           self.cursor.execute(*args, **kwargs)
       except MySQLdb.MySQLError:
           self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
           raise

   def get_longdesc_id(self):
       '''get identity of longdesc field'''
       self.run('select fieldid from fielddefs where name = "longdesc"')
       ids = self.cursor.fetchall()
       if len(ids) != 1:
           raise util.Abort(_('unknown database schema'))
       return ids[0][0]

   def filter_real_bug_ids(self, ids):
       '''filter not-existing bug ids from list.'''
       self.run('select bug_id from bugs where bug_id in %s' % buglist(ids))
       return sorted([c[0] for c in self.cursor.fetchall()])

   def filter_unknown_bug_ids(self, node, ids):
       '''filter bug ids from list that already refer to this changeset.'''

       self.run('''select bug_id from longdescs where
                   bug_id in %s and thetext like "%%%s%%"''' %
                (buglist(ids), short(node)))
       unknown = set(ids)
       for (id,) in self.cursor.fetchall():
           self.ui.status(_('bug %d already knows about changeset %s\n') %
                          (id, short(node)))
           unknown.discard(id)
       return sorted(unknown)

   def notify(self, ids, committer):
       '''tell bugzilla to send mail.'''

       self.ui.status(_('telling bugzilla to send mail:\n'))
       (user, userid) = self.get_bugzilla_user(committer)
       for id in ids:
           self.ui.status(_('  bug %s\n') % id)
           cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
           bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
           try:
               # Backwards-compatible with old notify string, which
               # took one string. This will throw with a new format
               # string.
               cmd = cmdfmt % id
           except TypeError:
               cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
           self.ui.note(_('running notify command %s\n') % cmd)
           fp = util.popen('(%s) 2>&1' % cmd)
           out = fp.read()
           ret = fp.close()
           if ret:
               self.ui.warn(out)
               raise util.Abort(_('bugzilla notify command %s') %
                                util.explain_exit(ret)[0])
       self.ui.status(_('done\n'))

   def get_user_id(self, user):
       '''look up numeric bugzilla user id.'''
       try:
           return self.user_ids[user]
       except KeyError:
           try:
               userid = int(user)
           except ValueError:
               self.ui.note(_('looking up user %s\n') % user)
               self.run('''select userid from profiles
                           where login_name like %s''', user)
               all = self.cursor.fetchall()
               if len(all) != 1:
                   raise KeyError(user)
               userid = int(all[0][0])
           self.user_ids[user] = userid
           return userid

   def map_committer(self, user):
       '''map name of committer to bugzilla user name.'''
       for committer, bzuser in self.ui.configitems('usermap'):
           if committer.lower() == user.lower():
               return bzuser
       return user

   def get_bugzilla_user(self, committer):
       '''see if committer is a registered bugzilla user. Return
       bugzilla username and userid if so. If not, return default
       bugzilla username and userid.'''
       user = self.map_committer(committer)
       try:
           userid = self.get_user_id(user)
       except KeyError:
           try:
               defaultuser = self.ui.config('bugzilla', 'bzuser')
               if not defaultuser:
                   raise util.Abort(_('cannot find bugzilla user id for %s') %
                                    user)
               userid = self.get_user_id(defaultuser)
               user = defaultuser
           except KeyError:
               raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
                                (user, defaultuser))
       return (user, userid)

   def add_comment(self, bugid, text, committer):
       '''add comment to bug. try adding comment as committer of
       changeset, otherwise as default bugzilla user.'''
       (user, userid) = self.get_bugzilla_user(committer)
       now = time.strftime('%Y-%m-%d %H:%M:%S')
       self.run('''insert into longdescs
                   (bug_id, who, bug_when, thetext)
                   values (%s, %s, %s, %s)''',
                (bugid, userid, now, text))
       self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
                   values (%s, %s, %s, %s)''',
                (bugid, userid, now, self.longdesc_id))
       self.conn.commit()

class bugzilla_2_18(bugzilla_2_16):
   '''support for bugzilla 2.18 series.'''

   def __init__(self, ui):
       bugzilla_2_16.__init__(self, ui)
       self.default_notify = "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"

class bugzilla_3_0(bugzilla_2_18):
   '''support for bugzilla 3.0 series.'''

   def __init__(self, ui):
       bugzilla_2_18.__init__(self, ui)

   def get_longdesc_id(self):
       '''get identity of longdesc field'''
       self.run('select id from fielddefs where name = "longdesc"')
       ids = self.cursor.fetchall()
       if len(ids) != 1:
           raise util.Abort(_('unknown database schema'))
       return ids[0][0]

class bugzilla(object):
   # supported versions of bugzilla. different versions have
   # different schemas.
   _versions = {
       '2.16': bugzilla_2_16,
       '2.18': bugzilla_2_18,
       '3.0':  bugzilla_3_0
       }

   _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
                      r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')

   _bz = None

   def __init__(self, ui, repo):
       self.ui = ui
       self.repo = repo

   def bz(self):
       '''return object that knows how to talk to bugzilla version in
       use.'''

       if bugzilla._bz is None:
           bzversion = self.ui.config('bugzilla', 'version')
           try:
               bzclass = bugzilla._versions[bzversion]
           except KeyError:
               raise util.Abort(_('bugzilla version %s not supported') %
                                bzversion)
           bugzilla._bz = bzclass(self.ui)
       return bugzilla._bz

   def __getattr__(self, key):
       return getattr(self.bz(), key)

   _bug_re = None
   _split_re = None

   def find_bug_ids(self, ctx):
       '''find valid bug ids that are referred to in changeset
       comments and that do not already have references to this
       changeset.'''

       if bugzilla._bug_re is None:
           bugzilla._bug_re = re.compile(
               self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
               re.IGNORECASE)
           bugzilla._split_re = re.compile(r'\D+')
       start = 0
       ids = set()
       while True:
           m = bugzilla._bug_re.search(ctx.description(), start)
           if not m:
               break
           start = m.end()
           for id in bugzilla._split_re.split(m.group(1)):
               if not id: continue
               ids.add(int(id))
       if ids:
           ids = self.filter_real_bug_ids(ids)
       if ids:
           ids = self.filter_unknown_bug_ids(ctx.node(), ids)
       return ids

   def update(self, bugid, ctx):
       '''update bugzilla bug with reference to changeset.'''

       def webroot(root):
           '''strip leading prefix of repo root and turn into
           url-safe path.'''
           count = int(self.ui.config('bugzilla', 'strip', 0))
           root = util.pconvert(root)
           while count > 0:
               c = root.find('/')
               if c == -1:
                   break
               root = root[c+1:]
               count -= 1
           return root

       mapfile = self.ui.config('bugzilla', 'style')
       tmpl = self.ui.config('bugzilla', 'template')
       t = cmdutil.changeset_templater(self.ui, self.repo,
                                       False, None, mapfile, False)
       if not mapfile and not tmpl:
           tmpl = _('changeset {node|short} in repo {root} refers '
                    'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
       if tmpl:
           tmpl = templater.parsestring(tmpl, quoted=False)
           t.use_template(tmpl)
       self.ui.pushbuffer()
       t.show(ctx, changes=ctx.changeset(),
              bug=str(bugid),
              hgweb=self.ui.config('web', 'baseurl'),
              root=self.repo.root,
              webroot=webroot(self.repo.root))
       data = self.ui.popbuffer()
       self.add_comment(bugid, data, util.email(ctx.user()))

def hook(ui, repo, hooktype, node=None, **kwargs):
   '''add comment to bugzilla for each changeset that refers to a
   bugzilla bug id. only add a comment once per bug, so same change
   seen multiple times does not fill bug with duplicate data.'''
   try:
       import MySQLdb as mysql
       global MySQLdb
       MySQLdb = mysql
   except ImportError, err:
       raise util.Abort(_('python mysql support not available: %s') % err)

   if node is None:
       raise util.Abort(_('hook type %s does not pass a changeset id') %
                        hooktype)
   try:
       bz = bugzilla(ui, repo)
       ctx = repo[node]
       ids = bz.find_bug_ids(ctx)
       if ids:
           for id in ids:
               bz.update(id, ctx)
           bz.notify(ids, util.email(ctx.user()))
   except MySQLdb.MySQLError, err:
       raise util.Abort(_('database error: %s') % err[1])