##// END OF EJS Templates
add bugzilla integration hook. example of writing hook in python....
Vadim Gelfer -
r2192:2be3ac7a default
parent child Browse files
Show More
@@ -0,0 +1,293 b''
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
General Comments 0
You need to be logged in to leave comments. Login now