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