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