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