|
1 # Author: Dmitriy Morozov <hg@foxcub.org>, 2007 -- 2009 |
|
2 |
|
3 """A very simple and lightweight issue tracker for Mercurial.""" |
|
4 |
|
5 from mercurial import hg, util, commands |
|
6 from mercurial.i18n import _ |
|
7 import os, time, random, mailbox, glob, socket, ConfigParser |
|
8 import mimetypes |
|
9 from email import encoders |
|
10 from email.generator import Generator |
|
11 from email.mime.audio import MIMEAudio |
|
12 from email.mime.base import MIMEBase |
|
13 from email.mime.image import MIMEImage |
|
14 from email.mime.multipart import MIMEMultipart |
|
15 from email.mime.text import MIMEText |
|
16 from itertools import izip |
|
17 |
|
18 |
|
19 state = { 'new': ['new'], |
|
20 'resolved': ['fixed', 'resolved'] } |
|
21 default_state = 'new' |
|
22 default_issues_dir = ".issues" |
|
23 filter_prefix = ".filter" |
|
24 date_format = '%a, %d %b %Y %H:%M:%S %1%2' |
|
25 maildir_dirs = ['new','cur','tmp'] |
|
26 default_format = '%(id)s (%(len)3d) [%(state)s]: %(Subject)s' |
|
27 |
|
28 def ilist(ui, repo, **opts): |
|
29 """List issues associated with the project""" |
|
30 |
|
31 # Process options |
|
32 show_all = opts['all'] |
|
33 properties = [] |
|
34 match_date, date_match = False, lambda x: True |
|
35 if opts['date']: |
|
36 match_date, date_match = True, util.matchdate(opts['date']) |
|
37 order = 'new' |
|
38 if opts['order']: |
|
39 order = opts['order'] |
|
40 |
|
41 # Formats |
|
42 formats = _read_formats(ui) |
|
43 |
|
44 # Find issues |
|
45 issues_dir = ui.config('artemis', 'issues', default = default_issues_dir) |
|
46 issues_path = os.path.join(repo.root, issues_dir) |
|
47 if not os.path.exists(issues_path): return |
|
48 |
|
49 issues = glob.glob(os.path.join(issues_path, '*')) |
|
50 |
|
51 _create_all_missing_dirs(issues_path, issues) |
|
52 |
|
53 # Process filter |
|
54 if opts['filter']: |
|
55 filters = glob.glob(os.path.join(issues_path, filter_prefix + '*')) |
|
56 config = ConfigParser.SafeConfigParser() |
|
57 config.read(filters) |
|
58 if not config.has_section(opts['filter']): |
|
59 ui.write('No filter %s defined\n' % opts['filter']) |
|
60 else: |
|
61 properties += config.items(opts['filter']) |
|
62 |
|
63 cmd_properties = _get_properties(opts['property']) |
|
64 list_properties = [p[0] for p in cmd_properties if len(p) == 1] |
|
65 list_properties_dict = {} |
|
66 properties += filter(lambda p: len(p) > 1, cmd_properties) |
|
67 |
|
68 summaries = [] |
|
69 for issue in issues: |
|
70 mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage) |
|
71 root = _find_root_key(mbox) |
|
72 if not root: continue |
|
73 property_match = True |
|
74 for property,value in properties: |
|
75 if value: |
|
76 property_match = property_match and (mbox[root][property] == value) |
|
77 else: |
|
78 property_match = property_match and (property not in mbox[root]) |
|
79 |
|
80 if not show_all and (not properties or not property_match) and (properties or mbox[root]['State'].upper() in [f.upper() for f in state['resolved']]): continue |
|
81 if match_date and not date_match(util.parsedate(mbox[root]['date'])[0]): continue |
|
82 |
|
83 if not list_properties: |
|
84 summaries.append((_summary_line(mbox, root, issue[len(issues_path)+1:], formats), # +1 for trailing / |
|
85 _find_mbox_date(mbox, root, order))) |
|
86 else: |
|
87 for lp in list_properties: |
|
88 if lp in mbox[root]: list_properties_dict.setdefault(lp, set()).add(mbox[root][lp]) |
|
89 |
|
90 if not list_properties: |
|
91 summaries.sort(lambda (s1,d1),(s2,d2): cmp(d2,d1)) |
|
92 for s,d in summaries: |
|
93 ui.write(s + '\n') |
|
94 else: |
|
95 for lp in list_properties_dict.keys(): |
|
96 ui.write("%s:\n" % lp) |
|
97 for value in sorted(list_properties_dict[lp]): |
|
98 ui.write(" %s\n" % value) |
|
99 |
|
100 |
|
101 def iadd(ui, repo, id = None, comment = 0, **opts): |
|
102 """Adds a new issue, or comment to an existing issue ID or its comment COMMENT""" |
|
103 |
|
104 comment = int(comment) |
|
105 |
|
106 # First, make sure issues have a directory |
|
107 issues_dir = ui.config('artemis', 'issues', default = default_issues_dir) |
|
108 issues_path = os.path.join(repo.root, issues_dir) |
|
109 if not os.path.exists(issues_path): os.mkdir(issues_path) |
|
110 |
|
111 if id: |
|
112 issue_fn, issue_id = _find_issue(ui, repo, id) |
|
113 if not issue_fn: |
|
114 ui.warn('No such issue\n') |
|
115 return |
|
116 _create_missing_dirs(issues_path, issue_id) |
|
117 mbox = mailbox.Maildir(issue_fn, factory=mailbox.MaildirMessage) |
|
118 keys = _order_keys_date(mbox) |
|
119 root = keys[0] |
|
120 |
|
121 user = ui.username() |
|
122 |
|
123 default_issue_text = "From: %s\nDate: %s\n" % (user, util.datestr(format = date_format)) |
|
124 if not id: |
|
125 default_issue_text += "State: %s\n" % default_state |
|
126 default_issue_text += "Subject: brief description\n\n" |
|
127 else: |
|
128 subject = mbox[(comment < len(mbox) and keys[comment]) or root]['Subject'] |
|
129 if not subject.startswith('Re: '): subject = 'Re: ' + subject |
|
130 default_issue_text += "Subject: %s\n\n" % subject |
|
131 default_issue_text += "Detailed description." |
|
132 |
|
133 # Get properties, and figure out if we need an explicit comment |
|
134 properties = _get_properties(opts['property']) |
|
135 no_comment = id and properties and opts['no_property_comment'] |
|
136 message = opts['message'] |
|
137 |
|
138 # Create the text |
|
139 if message: |
|
140 if not id: |
|
141 state_str = 'State: %s\n' % default_state |
|
142 else: |
|
143 state_str = '' |
|
144 issue = "From: %s\nDate: %s\nSubject: %s\n%s" % \ |
|
145 (user, util.datestr(format=date_format), message, state_str) |
|
146 elif not no_comment: |
|
147 issue = ui.edit(default_issue_text, user) |
|
148 |
|
149 if issue.strip() == '': |
|
150 ui.warn('Empty issue, ignoring\n') |
|
151 return |
|
152 if issue.strip() == default_issue_text: |
|
153 ui.warn('Unchanged issue text, ignoring\n') |
|
154 return |
|
155 else: |
|
156 # Write down a comment about updated properties |
|
157 properties_subject = ', '.join(['%s=%s' % (property, value) for (property, value) in properties]) |
|
158 |
|
159 issue = "From: %s\nDate: %s\nSubject: changed properties (%s)\n" % \ |
|
160 (user, util.datestr(format = date_format), properties_subject) |
|
161 |
|
162 # Create the message |
|
163 msg = mailbox.MaildirMessage(issue) |
|
164 if opts['attach']: |
|
165 outer = _attach_files(msg, opts['attach']) |
|
166 else: |
|
167 outer = msg |
|
168 |
|
169 # Pick random filename |
|
170 if not id: |
|
171 issue_fn = issues_path |
|
172 while os.path.exists(issue_fn): |
|
173 issue_id = _random_id() |
|
174 issue_fn = os.path.join(issues_path, issue_id) |
|
175 mbox = mailbox.Maildir(issue_fn, factory=mailbox.MaildirMessage) |
|
176 keys = _order_keys_date(mbox) |
|
177 # else: issue_fn already set |
|
178 |
|
179 # Add message to the mailbox |
|
180 mbox.lock() |
|
181 if id and comment >= len(mbox): |
|
182 ui.warn('No such comment number in mailbox, commenting on the issue itself\n') |
|
183 |
|
184 if not id: |
|
185 outer.add_header('Message-Id', "<%s-0-artemis@%s>" % (issue_id, socket.gethostname())) |
|
186 else: |
|
187 root = keys[0] |
|
188 outer.add_header('Message-Id', "<%s-%s-artemis@%s>" % (issue_id, _random_id(), socket.gethostname())) |
|
189 outer.add_header('References', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id']) |
|
190 outer.add_header('In-Reply-To', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id']) |
|
191 new_bug_path = issue_fn[(len(repo.root)+1):] + '/new/' + mbox.add(outer) # + 1 for the trailing / |
|
192 commands.add(ui, repo, new_bug_path) |
|
193 |
|
194 # Fix properties in the root message |
|
195 if properties: |
|
196 root = _find_root_key(mbox) |
|
197 msg = mbox[root] |
|
198 for property, value in properties: |
|
199 if property in msg: |
|
200 msg.replace_header(property, value) |
|
201 else: |
|
202 msg.add_header(property, value) |
|
203 mbox[root] = msg |
|
204 |
|
205 mbox.close() |
|
206 |
|
207 if opts['commit']: |
|
208 commands.commit(ui, repo, issue_fn) |
|
209 |
|
210 # If adding issue, add the new mailbox to the repository |
|
211 if not id: |
|
212 ui.status('Added new issue %s\n' % issue_id) |
|
213 else: |
|
214 _show_mbox(ui, mbox, 0) |
|
215 |
|
216 def ishow(ui, repo, id, comment = 0, **opts): |
|
217 """Shows issue ID, or possibly its comment COMMENT""" |
|
218 |
|
219 comment = int(comment) |
|
220 issue, id = _find_issue(ui, repo, id) |
|
221 if not issue: |
|
222 return ui.warn('No such issue\n') |
|
223 |
|
224 issues_dir = ui.config('artemis', 'issues', default = default_issues_dir) |
|
225 _create_missing_dirs(os.path.join(repo.root, issues_dir), issue) |
|
226 |
|
227 if opts.get('mutt'): |
|
228 return util.system('mutt -R -f %s' % issue) |
|
229 |
|
230 mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage) |
|
231 |
|
232 if opts['all']: |
|
233 ui.write('='*70 + '\n') |
|
234 i = 0 |
|
235 keys = _order_keys_date(mbox) |
|
236 for k in keys: |
|
237 _write_message(ui, mbox[k], i, skip = opts['skip']) |
|
238 ui.write('-'*70 + '\n') |
|
239 i += 1 |
|
240 return |
|
241 |
|
242 _show_mbox(ui, mbox, comment, skip = opts['skip']) |
|
243 |
|
244 if opts['extract']: |
|
245 attachment_numbers = map(int, opts['extract']) |
|
246 keys = _order_keys_date(mbox) |
|
247 msg = mbox[keys[comment]] |
|
248 counter = 1 |
|
249 for part in msg.walk(): |
|
250 ctype = part.get_content_type() |
|
251 maintype, subtype = ctype.split('/', 1) |
|
252 if maintype == 'multipart' or ctype == 'text/plain': continue |
|
253 if counter in attachment_numbers: |
|
254 filename = part.get_filename() |
|
255 if not filename: |
|
256 ext = mimetypes.guess_extension(part.get_content_type()) or '' |
|
257 filename = 'attachment-%03d%s' % (counter, ext) |
|
258 else: |
|
259 filename = os.path.basename(filename) |
|
260 fp = open(filename, 'wb') |
|
261 fp.write(part.get_payload(decode = True)) |
|
262 fp.close() |
|
263 counter += 1 |
|
264 |
|
265 |
|
266 def _find_issue(ui, repo, id): |
|
267 issues_dir = ui.config('artemis', 'issues', default = default_issues_dir) |
|
268 issues_path = os.path.join(repo.root, issues_dir) |
|
269 if not os.path.exists(issues_path): return False |
|
270 |
|
271 issues = glob.glob(os.path.join(issues_path, id + '*')) |
|
272 |
|
273 if len(issues) == 0: |
|
274 return False, 0 |
|
275 elif len(issues) > 1: |
|
276 ui.status("Multiple choices:\n") |
|
277 for i in issues: ui.status(' ', i[len(issues_path)+1:], '\n') |
|
278 return False, 0 |
|
279 |
|
280 return issues[0], issues[0][len(issues_path)+1:] |
|
281 |
|
282 def _get_properties(property_list): |
|
283 return [p.split('=') for p in property_list] |
|
284 |
|
285 def _write_message(ui, message, index = 0, skip = None): |
|
286 if index: ui.write("Comment: %d\n" % index) |
|
287 if ui.verbose: |
|
288 _show_text(ui, message.as_string().strip(), skip) |
|
289 else: |
|
290 if 'From' in message: ui.write('From: %s\n' % message['From']) |
|
291 if 'Date' in message: ui.write('Date: %s\n' % message['Date']) |
|
292 if 'Subject' in message: ui.write('Subject: %s\n' % message['Subject']) |
|
293 if 'State' in message: ui.write('State: %s\n' % message['State']) |
|
294 counter = 1 |
|
295 for part in message.walk(): |
|
296 ctype = part.get_content_type() |
|
297 maintype, subtype = ctype.split('/', 1) |
|
298 if maintype == 'multipart': continue |
|
299 if ctype == 'text/plain': |
|
300 ui.write('\n') |
|
301 _show_text(ui, part.get_payload().strip(), skip) |
|
302 else: |
|
303 filename = part.get_filename() |
|
304 ui.write('\n' + '%d: Attachment [%s, %s]: %s' % (counter, ctype, _humanreadable(len(part.get_payload())), filename) + '\n') |
|
305 counter += 1 |
|
306 |
|
307 def _show_text(ui, text, skip = None): |
|
308 for line in text.splitlines(): |
|
309 if not skip or not line.startswith(skip): |
|
310 ui.write(line + '\n') |
|
311 ui.write('\n') |
|
312 |
|
313 def _show_mbox(ui, mbox, comment, **opts): |
|
314 # Output the issue (or comment) |
|
315 if comment >= len(mbox): |
|
316 comment = 0 |
|
317 ui.warn('Comment out of range, showing the issue itself\n') |
|
318 keys = _order_keys_date(mbox) |
|
319 root = keys[0] |
|
320 msg = mbox[keys[comment]] |
|
321 ui.write('='*70 + '\n') |
|
322 if comment: |
|
323 ui.write('Subject: %s\n' % mbox[root]['Subject']) |
|
324 ui.write('State: %s\n' % mbox[root]['State']) |
|
325 ui.write('-'*70 + '\n') |
|
326 _write_message(ui, msg, comment, skip = ('skip' in opts) and opts['skip']) |
|
327 ui.write('-'*70 + '\n') |
|
328 |
|
329 # Read the mailbox into the messages and children dictionaries |
|
330 messages = {} |
|
331 children = {} |
|
332 i = 0 |
|
333 for k in keys: |
|
334 m = mbox[k] |
|
335 messages[m['Message-Id']] = (i,m) |
|
336 children.setdefault(m['In-Reply-To'], []).append(m['Message-Id']) |
|
337 i += 1 |
|
338 children[None] = [] # Safeguard against infinte loop on empty Message-Id |
|
339 |
|
340 # Iterate over children |
|
341 id = msg['Message-Id'] |
|
342 id_stack = (id in children and map(lambda x: (x, 1), reversed(children[id]))) or [] |
|
343 if not id_stack: return |
|
344 ui.write('Comments:\n') |
|
345 while id_stack: |
|
346 id,offset = id_stack.pop() |
|
347 id_stack += (id in children and map(lambda x: (x, offset+1), reversed(children[id]))) or [] |
|
348 index, msg = messages[id] |
|
349 ui.write(' '*offset + '%d: [%s] %s\n' % (index, util.shortuser(msg['From']), msg['Subject'])) |
|
350 ui.write('-'*70 + '\n') |
|
351 |
|
352 def _find_root_key(maildir): |
|
353 for k,m in maildir.iteritems(): |
|
354 if 'in-reply-to' not in m: |
|
355 return k |
|
356 |
|
357 def _order_keys_date(mbox): |
|
358 keys = mbox.keys() |
|
359 root = _find_root_key(mbox) |
|
360 keys.sort(lambda k1,k2: -(k1 == root) or cmp(util.parsedate(mbox[k1]['date']), util.parsedate(mbox[k2]['date']))) |
|
361 return keys |
|
362 |
|
363 def _find_mbox_date(mbox, root, order): |
|
364 if order == 'latest': |
|
365 keys = _order_keys_date(mbox) |
|
366 msg = mbox[keys[-1]] |
|
367 else: # new |
|
368 msg = mbox[root] |
|
369 return util.parsedate(msg['date']) |
|
370 |
|
371 def _random_id(): |
|
372 return "%x" % random.randint(2**63, 2**64-1) |
|
373 |
|
374 def _create_missing_dirs(issues_path, issue): |
|
375 for d in maildir_dirs: |
|
376 path = os.path.join(issues_path,issue,d) |
|
377 if not os.path.exists(path): os.mkdir(path) |
|
378 |
|
379 def _create_all_missing_dirs(issues_path, issues): |
|
380 for i in issues: |
|
381 _create_missing_dirs(issues_path, i) |
|
382 |
|
383 def _humanreadable(size): |
|
384 if size > 1024*1024: |
|
385 return '%5.1fM' % (float(size) / (1024*1024)) |
|
386 elif size > 1024: |
|
387 return '%5.1fK' % (float(size) / 1024) |
|
388 else: |
|
389 return '%dB' % size |
|
390 |
|
391 def _attach_files(msg, filenames): |
|
392 outer = MIMEMultipart() |
|
393 for k in msg.keys(): outer[k] = msg[k] |
|
394 outer.attach(MIMEText(msg.get_payload())) |
|
395 |
|
396 for filename in filenames: |
|
397 ctype, encoding = mimetypes.guess_type(filename) |
|
398 if ctype is None or encoding is not None: |
|
399 # No guess could be made, or the file is encoded (compressed), so |
|
400 # use a generic bag-of-bits type. |
|
401 ctype = 'application/octet-stream' |
|
402 maintype, subtype = ctype.split('/', 1) |
|
403 if maintype == 'text': |
|
404 fp = open(filename) |
|
405 # Note: we should handle calculating the charset |
|
406 attachment = MIMEText(fp.read(), _subtype=subtype) |
|
407 fp.close() |
|
408 elif maintype == 'image': |
|
409 fp = open(filename, 'rb') |
|
410 attachment = MIMEImage(fp.read(), _subtype=subtype) |
|
411 fp.close() |
|
412 elif maintype == 'audio': |
|
413 fp = open(filename, 'rb') |
|
414 attachment = MIMEAudio(fp.read(), _subtype=subtype) |
|
415 fp.close() |
|
416 else: |
|
417 fp = open(filename, 'rb') |
|
418 attachment = MIMEBase(maintype, subtype) |
|
419 attachment.set_payload(fp.read()) |
|
420 fp.close() |
|
421 # Encode the payload using Base64 |
|
422 encoders.encode_base64(attachment) |
|
423 # Set the filename parameter |
|
424 attachment.add_header('Content-Disposition', 'attachment', filename=os.path.basename(filename)) |
|
425 outer.attach(attachment) |
|
426 return outer |
|
427 |
|
428 def _read_formats(ui): |
|
429 formats = [] |
|
430 global default_format |
|
431 |
|
432 for k,v in ui.configitems('artemis'): |
|
433 if not k.startswith('format'): continue |
|
434 if k == 'format': |
|
435 default_format = v |
|
436 continue |
|
437 formats.append((k.split(':')[1], v)) |
|
438 |
|
439 return formats |
|
440 |
|
441 def _format_match(props, formats): |
|
442 for k,v in formats: |
|
443 eq = k.split('&') |
|
444 eq = [e.split('*') for e in eq] |
|
445 for e in eq: |
|
446 if props[e[0]] != e[1]: |
|
447 break |
|
448 else: |
|
449 return v |
|
450 |
|
451 return default_format |
|
452 |
|
453 def _summary_line(mbox, root, issue, formats): |
|
454 props = PropertiesDictionary(mbox[root]) |
|
455 props['id'] = issue |
|
456 props['len'] = len(mbox)-1 # number of replies (-1 for self) |
|
457 |
|
458 return _format_match(props, formats) % props |
|
459 |
|
460 class PropertiesDictionary(dict): |
|
461 def __init__(self, msg): |
|
462 # Borrowed from termcolor |
|
463 for k,v in zip(['bold', 'dark', '', 'underline', 'blink', '', 'reverse', 'concealed'], range(1, 9)) + \ |
|
464 zip(['grey', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'], range(30, 38)): |
|
465 self[k] = '\033[' + str(v) + 'm' |
|
466 self['reset'] = '\033[0m' |
|
467 del self[''] |
|
468 |
|
469 for k,v in msg.items(): |
|
470 self[k] = v |
|
471 |
|
472 def __contains__(self, k): |
|
473 return super(PropertiesDictionary, self).__contains__(k.lower()) |
|
474 |
|
475 def __getitem__(self, k): |
|
476 if k not in self: return '' |
|
477 return super(PropertiesDictionary, self).__getitem__(k.lower()) |
|
478 |
|
479 def __setitem__(self, k, v): |
|
480 super(PropertiesDictionary, self).__setitem__(k.lower(), v) |
|
481 |
|
482 |
|
483 cmdtable = { |
|
484 'ilist': (ilist, |
|
485 [('a', 'all', False, |
|
486 'list all issues (by default only those with state new)'), |
|
487 ('p', 'property', [], |
|
488 'list issues with specific field values (e.g., -p state=fixed); lists all possible values of a property if no = sign'), |
|
489 ('o', 'order', 'new', 'order of the issues; choices: "new" (date submitted), "latest" (date of the last message)'), |
|
490 ('d', 'date', '', 'restrict to issues matching the date (e.g., -d ">12/28/2007)"'), |
|
491 ('f', 'filter', '', 'restrict to pre-defined filter (in %s/%s*)' % (default_issues_dir, filter_prefix))], |
|
492 _('hg ilist [OPTIONS]')), |
|
493 'iadd': (iadd, |
|
494 [('a', 'attach', [], |
|
495 'attach file(s) (e.g., -a filename1 -a filename2)'), |
|
496 ('p', 'property', [], |
|
497 'update properties (e.g., -p state=fixed)'), |
|
498 ('n', 'no-property-comment', None, |
|
499 'do not add a comment about changed properties'), |
|
500 ('m', 'message', '', |
|
501 'use <text> as an issue subject'), |
|
502 ('c', 'commit', False, |
|
503 'perform a commit after the addition')], |
|
504 _('hg iadd [OPTIONS] [ID] [COMMENT]')), |
|
505 'ishow': (ishow, |
|
506 [('a', 'all', None, 'list all comments'), |
|
507 ('s', 'skip', '>', 'skip lines starting with a substring'), |
|
508 ('x', 'extract', [], 'extract attachments (provide attachment number as argument)'), |
|
509 ('', 'mutt', False, 'use mutt to show issue')], |
|
510 _('hg ishow [OPTIONS] ID [COMMENT]')), |
|
511 } |
|
512 |
|
513 # vim: expandtab |