Mercurial > hg > mercurial-crew-with-dirclash
comparison hgext/bugzilla.py @ 2192:2be3ac7abc21
add bugzilla integration hook. example of writing hook in python.
hook updates bugzilla bugs when it sees commit comments that mention
bug id, such as "i fixed bug 77".
only bugzilla 2.16 supported yet, but easy to extend. bugzilla versions
have different schema, i have not used later than 2.16.
author | Vadim Gelfer <vadim.gelfer@gmail.com> |
---|---|
date | Wed, 03 May 2006 14:40:39 -0700 |
parents | |
children | 5de8b44f0446 afe24f5b7a9e |
comparison
equal
deleted
inserted
replaced
2191:c2e43535d4d1 | 2192:2be3ac7abc21 |
---|---|
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 |