##// END OF EJS Templates
bugzilla: use set instead of dict
Benoit Boissinot -
r8455:a858b54d default
parent child Browse files
Show More
@@ -1,417 +1,416 b''
1 1 # bugzilla.py - bugzilla integration for mercurial
2 2 #
3 3 # Copyright 2006 Vadim Gelfer <vadim.gelfer@gmail.com>
4 4 #
5 5 # This software may be used and distributed according to the terms of the
6 6 # GNU General Public License version 2, incorporated herein by reference.
7 7
8 8 '''Bugzilla integration
9 9
10 10 This hook extension adds comments on bugs in Bugzilla when changesets
11 11 that refer to bugs by Bugzilla ID are seen. The hook does not change
12 12 bug status.
13 13
14 14 The hook updates the Bugzilla database directly. Only Bugzilla
15 15 installations using MySQL are supported.
16 16
17 17 The hook relies on a Bugzilla script to send bug change notification
18 18 emails. That script changes between Bugzilla versions; the
19 19 'processmail' script used prior to 2.18 is replaced in 2.18 and
20 20 subsequent versions by 'config/sendbugmail.pl'. Note that these will
21 21 be run by Mercurial as the user pushing the change; you will need to
22 22 ensure the Bugzilla install file permissions are set appropriately.
23 23
24 24 Configuring the extension:
25 25
26 26 [bugzilla]
27 27
28 28 host Hostname of the MySQL server holding the Bugzilla
29 29 database.
30 30 db Name of the Bugzilla database in MySQL. Default 'bugs'.
31 31 user Username to use to access MySQL server. Default 'bugs'.
32 32 password Password to use to access MySQL server.
33 33 timeout Database connection timeout (seconds). Default 5.
34 34 version Bugzilla version. Specify '3.0' for Bugzilla versions
35 35 3.0 and later, '2.18' for Bugzilla versions from 2.18
36 36 and '2.16' for versions prior to 2.18.
37 37 bzuser Fallback Bugzilla user name to record comments with, if
38 38 changeset committer cannot be found as a Bugzilla user.
39 39 bzdir Bugzilla install directory. Used by default notify.
40 40 Default '/var/www/html/bugzilla'.
41 41 notify The command to run to get Bugzilla to send bug change
42 42 notification emails. Substitutes from a map with 3
43 43 keys, 'bzdir', 'id' (bug id) and 'user' (committer
44 44 bugzilla email). Default depends on version; from 2.18
45 45 it is "cd %(bzdir)s && perl -T contrib/sendbugmail.pl
46 46 %(id)s %(user)s".
47 47 regexp Regular expression to match bug IDs in changeset commit
48 48 message. Must contain one "()" group. The default
49 49 expression matches 'Bug 1234', 'Bug no. 1234', 'Bug
50 50 number 1234', 'Bugs 1234,5678', 'Bug 1234 and 5678' and
51 51 variations thereof. Matching is case insensitive.
52 52 style The style file to use when formatting comments.
53 53 template Template to use when formatting comments. Overrides
54 54 style if specified. In addition to the usual Mercurial
55 55 keywords, the extension specifies:
56 56 {bug} The Bugzilla bug ID.
57 57 {root} The full pathname of the Mercurial
58 58 repository.
59 59 {webroot} Stripped pathname of the Mercurial
60 60 repository.
61 61 {hgweb} Base URL for browsing Mercurial
62 62 repositories.
63 63 Default 'changeset {node|short} in repo {root} refers '
64 64 'to bug {bug}.\\ndetails:\\n\\t{desc|tabindent}'
65 65 strip The number of slashes to strip from the front of {root}
66 66 to produce {webroot}. Default 0.
67 67 usermap Path of file containing Mercurial committer ID to
68 68 Bugzilla user ID mappings. If specified, the file
69 69 should contain one mapping per line,
70 70 "committer"="Bugzilla user". See also the [usermap]
71 71 section.
72 72
73 73 [usermap]
74 74 Any entries in this section specify mappings of Mercurial
75 75 committer ID to Bugzilla user ID. See also [bugzilla].usermap.
76 76 "committer"="Bugzilla user"
77 77
78 78 [web]
79 79 baseurl Base URL for browsing Mercurial repositories. Reference
80 80 from templates as {hgweb}.
81 81
82 82 Activating the extension:
83 83
84 84 [extensions]
85 85 hgext.bugzilla =
86 86
87 87 [hooks]
88 88 # run bugzilla hook on every change pulled or pushed in here
89 89 incoming.bugzilla = python:hgext.bugzilla.hook
90 90
91 91 Example configuration:
92 92
93 93 This example configuration is for a collection of Mercurial
94 94 repositories in /var/local/hg/repos/ used with a local Bugzilla 3.2
95 95 installation in /opt/bugzilla-3.2.
96 96
97 97 [bugzilla]
98 98 host=localhost
99 99 password=XYZZY
100 100 version=3.0
101 101 bzuser=unknown@domain.com
102 102 bzdir=/opt/bugzilla-3.2
103 103 template=Changeset {node|short} in {root|basename}.\\n{hgweb}/{webroot}/rev/{node|short}\\n\\n{desc}\\n
104 104 strip=5
105 105
106 106 [web]
107 107 baseurl=http://dev.domain.com/hg
108 108
109 109 [usermap]
110 110 user@emaildomain.com=user.name@bugzilladomain.com
111 111
112 112 Commits add a comment to the Bugzilla bug record of the form:
113 113
114 114 Changeset 3b16791d6642 in repository-name.
115 115 http://dev.domain.com/hg/repository-name/rev/3b16791d6642
116 116
117 117 Changeset commit comment. Bug 1234.
118 118 '''
119 119
120 120 from mercurial.i18n import _
121 121 from mercurial.node import short
122 122 from mercurial import cmdutil, templater, util
123 123 import re, time
124 124
125 125 MySQLdb = None
126 126
127 127 def buglist(ids):
128 128 return '(' + ','.join(map(str, ids)) + ')'
129 129
130 130 class bugzilla_2_16(object):
131 131 '''support for bugzilla version 2.16.'''
132 132
133 133 def __init__(self, ui):
134 134 self.ui = ui
135 135 host = self.ui.config('bugzilla', 'host', 'localhost')
136 136 user = self.ui.config('bugzilla', 'user', 'bugs')
137 137 passwd = self.ui.config('bugzilla', 'password')
138 138 db = self.ui.config('bugzilla', 'db', 'bugs')
139 139 timeout = int(self.ui.config('bugzilla', 'timeout', 5))
140 140 usermap = self.ui.config('bugzilla', 'usermap')
141 141 if usermap:
142 142 self.ui.readconfig(usermap, sections=['usermap'])
143 143 self.ui.note(_('connecting to %s:%s as %s, password %s\n') %
144 144 (host, db, user, '*' * len(passwd)))
145 145 self.conn = MySQLdb.connect(host=host, user=user, passwd=passwd,
146 146 db=db, connect_timeout=timeout)
147 147 self.cursor = self.conn.cursor()
148 148 self.longdesc_id = self.get_longdesc_id()
149 149 self.user_ids = {}
150 150 self.default_notify = "cd %(bzdir)s && ./processmail %(id)s %(user)s"
151 151
152 152 def run(self, *args, **kwargs):
153 153 '''run a query.'''
154 154 self.ui.note(_('query: %s %s\n') % (args, kwargs))
155 155 try:
156 156 self.cursor.execute(*args, **kwargs)
157 157 except MySQLdb.MySQLError:
158 158 self.ui.note(_('failed query: %s %s\n') % (args, kwargs))
159 159 raise
160 160
161 161 def get_longdesc_id(self):
162 162 '''get identity of longdesc field'''
163 163 self.run('select fieldid from fielddefs where name = "longdesc"')
164 164 ids = self.cursor.fetchall()
165 165 if len(ids) != 1:
166 166 raise util.Abort(_('unknown database schema'))
167 167 return ids[0][0]
168 168
169 169 def filter_real_bug_ids(self, ids):
170 170 '''filter not-existing bug ids from list.'''
171 171 self.run('select bug_id from bugs where bug_id in %s' % buglist(ids))
172 172 return sorted([c[0] for c in self.cursor.fetchall()])
173 173
174 174 def filter_unknown_bug_ids(self, node, ids):
175 175 '''filter bug ids from list that already refer to this changeset.'''
176 176
177 177 self.run('''select bug_id from longdescs where
178 178 bug_id in %s and thetext like "%%%s%%"''' %
179 179 (buglist(ids), short(node)))
180 180 unknown = set(ids)
181 181 for (id,) in self.cursor.fetchall():
182 182 self.ui.status(_('bug %d already knows about changeset %s\n') %
183 183 (id, short(node)))
184 184 unknown.discard(id)
185 185 return sorted(unknown)
186 186
187 187 def notify(self, ids, committer):
188 188 '''tell bugzilla to send mail.'''
189 189
190 190 self.ui.status(_('telling bugzilla to send mail:\n'))
191 191 (user, userid) = self.get_bugzilla_user(committer)
192 192 for id in ids:
193 193 self.ui.status(_(' bug %s\n') % id)
194 194 cmdfmt = self.ui.config('bugzilla', 'notify', self.default_notify)
195 195 bzdir = self.ui.config('bugzilla', 'bzdir', '/var/www/html/bugzilla')
196 196 try:
197 197 # Backwards-compatible with old notify string, which
198 198 # took one string. This will throw with a new format
199 199 # string.
200 200 cmd = cmdfmt % id
201 201 except TypeError:
202 202 cmd = cmdfmt % {'bzdir': bzdir, 'id': id, 'user': user}
203 203 self.ui.note(_('running notify command %s\n') % cmd)
204 204 fp = util.popen('(%s) 2>&1' % cmd)
205 205 out = fp.read()
206 206 ret = fp.close()
207 207 if ret:
208 208 self.ui.warn(out)
209 209 raise util.Abort(_('bugzilla notify command %s') %
210 210 util.explain_exit(ret)[0])
211 211 self.ui.status(_('done\n'))
212 212
213 213 def get_user_id(self, user):
214 214 '''look up numeric bugzilla user id.'''
215 215 try:
216 216 return self.user_ids[user]
217 217 except KeyError:
218 218 try:
219 219 userid = int(user)
220 220 except ValueError:
221 221 self.ui.note(_('looking up user %s\n') % user)
222 222 self.run('''select userid from profiles
223 223 where login_name like %s''', user)
224 224 all = self.cursor.fetchall()
225 225 if len(all) != 1:
226 226 raise KeyError(user)
227 227 userid = int(all[0][0])
228 228 self.user_ids[user] = userid
229 229 return userid
230 230
231 231 def map_committer(self, user):
232 232 '''map name of committer to bugzilla user name.'''
233 233 for committer, bzuser in self.ui.configitems('usermap'):
234 234 if committer.lower() == user.lower():
235 235 return bzuser
236 236 return user
237 237
238 238 def get_bugzilla_user(self, committer):
239 239 '''see if committer is a registered bugzilla user. Return
240 240 bugzilla username and userid if so. If not, return default
241 241 bugzilla username and userid.'''
242 242 user = self.map_committer(committer)
243 243 try:
244 244 userid = self.get_user_id(user)
245 245 except KeyError:
246 246 try:
247 247 defaultuser = self.ui.config('bugzilla', 'bzuser')
248 248 if not defaultuser:
249 249 raise util.Abort(_('cannot find bugzilla user id for %s') %
250 250 user)
251 251 userid = self.get_user_id(defaultuser)
252 252 user = defaultuser
253 253 except KeyError:
254 254 raise util.Abort(_('cannot find bugzilla user id for %s or %s') %
255 255 (user, defaultuser))
256 256 return (user, userid)
257 257
258 258 def add_comment(self, bugid, text, committer):
259 259 '''add comment to bug. try adding comment as committer of
260 260 changeset, otherwise as default bugzilla user.'''
261 261 (user, userid) = self.get_bugzilla_user(committer)
262 262 now = time.strftime('%Y-%m-%d %H:%M:%S')
263 263 self.run('''insert into longdescs
264 264 (bug_id, who, bug_when, thetext)
265 265 values (%s, %s, %s, %s)''',
266 266 (bugid, userid, now, text))
267 267 self.run('''insert into bugs_activity (bug_id, who, bug_when, fieldid)
268 268 values (%s, %s, %s, %s)''',
269 269 (bugid, userid, now, self.longdesc_id))
270 270 self.conn.commit()
271 271
272 272 class bugzilla_2_18(bugzilla_2_16):
273 273 '''support for bugzilla 2.18 series.'''
274 274
275 275 def __init__(self, ui):
276 276 bugzilla_2_16.__init__(self, ui)
277 277 self.default_notify = "cd %(bzdir)s && perl -T contrib/sendbugmail.pl %(id)s %(user)s"
278 278
279 279 class bugzilla_3_0(bugzilla_2_18):
280 280 '''support for bugzilla 3.0 series.'''
281 281
282 282 def __init__(self, ui):
283 283 bugzilla_2_18.__init__(self, ui)
284 284
285 285 def get_longdesc_id(self):
286 286 '''get identity of longdesc field'''
287 287 self.run('select id from fielddefs where name = "longdesc"')
288 288 ids = self.cursor.fetchall()
289 289 if len(ids) != 1:
290 290 raise util.Abort(_('unknown database schema'))
291 291 return ids[0][0]
292 292
293 293 class bugzilla(object):
294 294 # supported versions of bugzilla. different versions have
295 295 # different schemas.
296 296 _versions = {
297 297 '2.16': bugzilla_2_16,
298 298 '2.18': bugzilla_2_18,
299 299 '3.0': bugzilla_3_0
300 300 }
301 301
302 302 _default_bug_re = (r'bugs?\s*,?\s*(?:#|nos?\.?|num(?:ber)?s?)?\s*'
303 303 r'((?:\d+\s*(?:,?\s*(?:and)?)?\s*)+)')
304 304
305 305 _bz = None
306 306
307 307 def __init__(self, ui, repo):
308 308 self.ui = ui
309 309 self.repo = repo
310 310
311 311 def bz(self):
312 312 '''return object that knows how to talk to bugzilla version in
313 313 use.'''
314 314
315 315 if bugzilla._bz is None:
316 316 bzversion = self.ui.config('bugzilla', 'version')
317 317 try:
318 318 bzclass = bugzilla._versions[bzversion]
319 319 except KeyError:
320 320 raise util.Abort(_('bugzilla version %s not supported') %
321 321 bzversion)
322 322 bugzilla._bz = bzclass(self.ui)
323 323 return bugzilla._bz
324 324
325 325 def __getattr__(self, key):
326 326 return getattr(self.bz(), key)
327 327
328 328 _bug_re = None
329 329 _split_re = None
330 330
331 331 def find_bug_ids(self, ctx):
332 332 '''find valid bug ids that are referred to in changeset
333 333 comments and that do not already have references to this
334 334 changeset.'''
335 335
336 336 if bugzilla._bug_re is None:
337 337 bugzilla._bug_re = re.compile(
338 338 self.ui.config('bugzilla', 'regexp', bugzilla._default_bug_re),
339 339 re.IGNORECASE)
340 340 bugzilla._split_re = re.compile(r'\D+')
341 341 start = 0
342 ids = {}
342 ids = set()
343 343 while True:
344 344 m = bugzilla._bug_re.search(ctx.description(), start)
345 345 if not m:
346 346 break
347 347 start = m.end()
348 348 for id in bugzilla._split_re.split(m.group(1)):
349 349 if not id: continue
350 ids[int(id)] = 1
351 ids = ids.keys()
350 ids.add(int(id))
352 351 if ids:
353 352 ids = self.filter_real_bug_ids(ids)
354 353 if ids:
355 354 ids = self.filter_unknown_bug_ids(ctx.node(), ids)
356 355 return ids
357 356
358 357 def update(self, bugid, ctx):
359 358 '''update bugzilla bug with reference to changeset.'''
360 359
361 360 def webroot(root):
362 361 '''strip leading prefix of repo root and turn into
363 362 url-safe path.'''
364 363 count = int(self.ui.config('bugzilla', 'strip', 0))
365 364 root = util.pconvert(root)
366 365 while count > 0:
367 366 c = root.find('/')
368 367 if c == -1:
369 368 break
370 369 root = root[c+1:]
371 370 count -= 1
372 371 return root
373 372
374 373 mapfile = self.ui.config('bugzilla', 'style')
375 374 tmpl = self.ui.config('bugzilla', 'template')
376 375 t = cmdutil.changeset_templater(self.ui, self.repo,
377 376 False, None, mapfile, False)
378 377 if not mapfile and not tmpl:
379 378 tmpl = _('changeset {node|short} in repo {root} refers '
380 379 'to bug {bug}.\ndetails:\n\t{desc|tabindent}')
381 380 if tmpl:
382 381 tmpl = templater.parsestring(tmpl, quoted=False)
383 382 t.use_template(tmpl)
384 383 self.ui.pushbuffer()
385 384 t.show(ctx, changes=ctx.changeset(),
386 385 bug=str(bugid),
387 386 hgweb=self.ui.config('web', 'baseurl'),
388 387 root=self.repo.root,
389 388 webroot=webroot(self.repo.root))
390 389 data = self.ui.popbuffer()
391 390 self.add_comment(bugid, data, util.email(ctx.user()))
392 391
393 392 def hook(ui, repo, hooktype, node=None, **kwargs):
394 393 '''add comment to bugzilla for each changeset that refers to a
395 394 bugzilla bug id. only add a comment once per bug, so same change
396 395 seen multiple times does not fill bug with duplicate data.'''
397 396 try:
398 397 import MySQLdb as mysql
399 398 global MySQLdb
400 399 MySQLdb = mysql
401 400 except ImportError, err:
402 401 raise util.Abort(_('python mysql support not available: %s') % err)
403 402
404 403 if node is None:
405 404 raise util.Abort(_('hook type %s does not pass a changeset id') %
406 405 hooktype)
407 406 try:
408 407 bz = bugzilla(ui, repo)
409 408 ctx = repo[node]
410 409 ids = bz.find_bug_ids(ctx)
411 410 if ids:
412 411 for id in ids:
413 412 bz.update(id, ctx)
414 413 bz.notify(ids, util.email(ctx.user()))
415 414 except MySQLdb.MySQLError, err:
416 415 raise util.Abort(_('database error: %s') % err[1])
417 416
General Comments 0
You need to be logged in to leave comments. Login now