comparison hgext/bugzilla.py @ 2192:2be3ac7abc21

add bugzilla integration hook. example of writing hook in python. hook updates bugzilla bugs when it sees commit comments that mention bug id, such as "i fixed bug 77". only bugzilla 2.16 supported yet, but easy to extend. bugzilla versions have different schema, i have not used later than 2.16.
author Vadim Gelfer <vadim.gelfer@gmail.com>
date Wed, 03 May 2006 14:40:39 -0700
parents
children 5de8b44f0446 afe24f5b7a9e
comparison
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