3 """A very simple and lightweight issue tracker for Mercurial.""" |
3 """A very simple and lightweight issue tracker for Mercurial.""" |
4 |
4 |
5 from mercurial import hg, util |
5 from mercurial import hg, util |
6 from mercurial.i18n import _ |
6 from mercurial.i18n import _ |
7 import os, time, random, mailbox, glob, socket, ConfigParser |
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 |
8 |
16 |
9 |
17 |
10 state = {'new': 'new', 'fixed': 'fixed'} |
18 state = {'new': 'new', 'fixed': 'fixed'} |
11 state['default'] = state['new'] |
19 state['default'] = state['new'] |
12 issues_dir = ".issues" |
20 issues_dir = ".issues" |
29 issues_path = os.path.join(repo.root, issues_dir) |
37 issues_path = os.path.join(repo.root, issues_dir) |
30 if not os.path.exists(issues_path): return |
38 if not os.path.exists(issues_path): return |
31 |
39 |
32 issues = glob.glob(os.path.join(issues_path, '*')) |
40 issues = glob.glob(os.path.join(issues_path, '*')) |
33 |
41 |
34 # Create missing dirs |
42 _create_all_missing_dirs(issues_path, issues) |
35 for i in issues: |
|
36 for d in maildir_dirs: |
|
37 path = os.path.join(issues_path,i,d) |
|
38 if not os.path.exists(path): os.mkdir(path) |
|
39 |
43 |
40 # Process filter |
44 # Process filter |
41 if opts['filter']: |
45 if opts['filter']: |
42 filters = glob.glob(os.path.join(issues_path, filter_prefix + '*')) |
46 filters = glob.glob(os.path.join(issues_path, filter_prefix + '*')) |
43 config = ConfigParser.SafeConfigParser() |
47 config = ConfigParser.SafeConfigParser() |
63 len(mbox)-1, # number of replies (-1 for self) |
67 len(mbox)-1, # number of replies (-1 for self) |
64 mbox[root]['State'], |
68 mbox[root]['State'], |
65 mbox[root]['Subject'])) |
69 mbox[root]['Subject'])) |
66 |
70 |
67 |
71 |
68 def iadd(ui, repo, id = None, comment = 0): |
72 def iadd(ui, repo, id = None, comment = 0, **opts): |
69 """Adds a new issue, or comment to an existing issue ID or its comment COMMENT""" |
73 """Adds a new issue, or comment to an existing issue ID or its comment COMMENT""" |
70 |
74 |
71 comment = int(comment) |
75 comment = int(comment) |
72 |
76 |
73 # First, make sure issues have a directory |
77 # First, make sure issues have a directory |
77 if id: |
81 if id: |
78 issue_fn, issue_id = _find_issue(ui, repo, id) |
82 issue_fn, issue_id = _find_issue(ui, repo, id) |
79 if not issue_fn: |
83 if not issue_fn: |
80 ui.warn('No such issue\n') |
84 ui.warn('No such issue\n') |
81 return |
85 return |
82 for d in maildir_dirs: |
86 _create_missing_dirs(issues_path, issue_id) |
83 path = os.path.join(issues_path,issue_id,d) |
|
84 if not os.path.exists(path): os.mkdir(path) |
|
85 |
87 |
86 user = ui.username() |
88 user = ui.username() |
87 |
89 |
88 default_issue_text = "From: %s\nDate: %s\n" % (user, util.datestr(format = date_format)) |
90 default_issue_text = "From: %s\nDate: %s\n" % (user, util.datestr(format = date_format)) |
89 if not id: |
91 if not id: |
99 ui.warn('Unchanged issue text, ignoring\n') |
101 ui.warn('Unchanged issue text, ignoring\n') |
100 return |
102 return |
101 |
103 |
102 # Create the message |
104 # Create the message |
103 msg = mailbox.MaildirMessage(issue) |
105 msg = mailbox.MaildirMessage(issue) |
104 #msg.set_from('artemis', True) |
106 if opts['attach']: |
|
107 outer = _attach_files(msg, opts['attach']) |
|
108 else: |
|
109 outer = msg |
105 |
110 |
106 # Pick random filename |
111 # Pick random filename |
107 if not id: |
112 if not id: |
108 issue_fn = issues_path |
113 issue_fn = issues_path |
109 while os.path.exists(issue_fn): |
114 while os.path.exists(issue_fn): |
117 mbox.lock() |
122 mbox.lock() |
118 if id and comment >= len(mbox): |
123 if id and comment >= len(mbox): |
119 ui.warn('No such comment number in mailbox, commenting on the issue itself\n') |
124 ui.warn('No such comment number in mailbox, commenting on the issue itself\n') |
120 |
125 |
121 if not id: |
126 if not id: |
122 msg.add_header('Message-Id', "<%s-0-artemis@%s>" % (issue_id, socket.gethostname())) |
127 outer.add_header('Message-Id', "<%s-0-artemis@%s>" % (issue_id, socket.gethostname())) |
123 else: |
128 else: |
124 root = keys[0] |
129 root = keys[0] |
125 msg.add_header('Message-Id', "<%s-%s-artemis@%s>" % (issue_id, _random_id(), socket.gethostname())) |
130 outer.add_header('Message-Id', "<%s-%s-artemis@%s>" % (issue_id, _random_id(), socket.gethostname())) |
126 msg.add_header('References', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id']) |
131 outer.add_header('References', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id']) |
127 msg.add_header('In-Reply-To', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id']) |
132 outer.add_header('In-Reply-To', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id']) |
128 repo.add([issue_fn[(len(repo.root)+1):] + '/new/' + mbox.add(msg)]) # +1 for the trailing / |
133 repo.add([issue_fn[(len(repo.root)+1):] + '/new/' + mbox.add(outer)]) # +1 for the trailing / |
129 mbox.close() |
134 mbox.close() |
130 |
135 |
131 # If adding issue, add the new mailbox to the repository |
136 # If adding issue, add the new mailbox to the repository |
132 if not id: |
137 if not id: |
133 ui.status('Added new issue %s\n' % issue_id) |
138 ui.status('Added new issue %s\n' % issue_id) |
138 |
143 |
139 comment = int(comment) |
144 comment = int(comment) |
140 issue, id = _find_issue(ui, repo, id) |
145 issue, id = _find_issue(ui, repo, id) |
141 if not issue: return |
146 if not issue: return |
142 |
147 |
143 # Create missing dirs |
148 _create_missing_dirs(os.path.join(repo.root, issues_dir), issue) |
144 for d in maildir_dirs: |
|
145 path = os.path.join(repo.root,issues_dir,issue,d) |
|
146 if not os.path.exists(path): os.mkdir(path) |
|
147 |
149 |
148 mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage) |
150 mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage) |
149 |
151 |
150 if opts['all']: |
152 if opts['all']: |
151 ui.write('='*70 + '\n') |
153 ui.write('='*70 + '\n') |
157 i += 1 |
159 i += 1 |
158 return |
160 return |
159 |
161 |
160 _show_mbox(ui, mbox, comment) |
162 _show_mbox(ui, mbox, comment) |
161 |
163 |
|
164 if opts['extract']: |
|
165 attachment_numbers = map(int, opts['extract']) |
|
166 keys = _order_keys_date(mbox) |
|
167 msg = mbox[keys[comment]] |
|
168 counter = 1 |
|
169 for part in msg.walk(): |
|
170 ctype = part.get_content_type() |
|
171 maintype, subtype = ctype.split('/', 1) |
|
172 if maintype == 'multipart' or ctype == 'text/plain': continue |
|
173 if counter in attachment_numbers: |
|
174 filename = part.get_filename() |
|
175 if not filename: |
|
176 ext = mimetypes.guess_extension(part.get_content_type()) or '' |
|
177 filename = 'attachment-%03d%s' % (counter, ext) |
|
178 fp = open(filename, 'wb') |
|
179 fp.write(part.get_payload(decode = True)) |
|
180 fp.close() |
|
181 counter += 1 |
|
182 |
162 |
183 |
163 def iupdate(ui, repo, id, **opts): |
184 def iupdate(ui, repo, id, **opts): |
164 """Update properties of issue ID""" |
185 """Update properties of issue ID""" |
165 |
186 |
166 issue, id = _find_issue(ui, repo, id) |
187 issue, id = _find_issue(ui, repo, id) |
167 if not issue: return |
188 if not issue: return |
168 |
189 |
169 # Create missing dirs |
190 _create_missing_dirs(os.path.join(repo.root, issues_dir), issue_id) |
170 for d in maildir_dirs: |
|
171 path = os.path.join(repo.root,issues_dir,issue,d) |
|
172 if not os.path.exists(path): os.mkdir(path) |
|
173 |
|
174 |
191 |
175 properties = _get_properties(opts['property']) |
192 properties = _get_properties(opts['property']) |
176 |
193 |
177 # Read the issue |
194 # Read the issue |
178 mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage) |
195 mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage) |
234 else: |
251 else: |
235 if 'From' in message: ui.write('From: %s\n' % message['From']) |
252 if 'From' in message: ui.write('From: %s\n' % message['From']) |
236 if 'Date' in message: ui.write('Date: %s\n' % message['Date']) |
253 if 'Date' in message: ui.write('Date: %s\n' % message['Date']) |
237 if 'Subject' in message: ui.write('Subject: %s\n' % message['Subject']) |
254 if 'Subject' in message: ui.write('Subject: %s\n' % message['Subject']) |
238 if 'State' in message: ui.write('State: %s\n' % message['State']) |
255 if 'State' in message: ui.write('State: %s\n' % message['State']) |
239 ui.write('\n' + message.get_payload().strip() + '\n') |
256 counter = 1 |
|
257 for part in message.walk(): |
|
258 ctype = part.get_content_type() |
|
259 maintype, subtype = ctype.split('/', 1) |
|
260 if maintype == 'multipart': continue |
|
261 if ctype == 'text/plain': |
|
262 ui.write('\n' + part.get_payload().strip() + '\n') |
|
263 else: |
|
264 filename = part.get_filename() |
|
265 ui.write('\n' + '%d: Attachment [%s, %s]: %s' % (counter, ctype, _humanreadable(len(part.get_payload())), filename) + '\n') |
|
266 counter += 1 |
240 |
267 |
241 def _show_mbox(ui, mbox, comment): |
268 def _show_mbox(ui, mbox, comment): |
242 # Output the issue (or comment) |
269 # Output the issue (or comment) |
243 if comment >= len(mbox): |
270 if comment >= len(mbox): |
244 comment = 0 |
271 comment = 0 |
295 return s[:-2] |
322 return s[:-2] |
296 |
323 |
297 def _random_id(): |
324 def _random_id(): |
298 return "%x" % random.randint(2**63, 2**64-1) |
325 return "%x" % random.randint(2**63, 2**64-1) |
299 |
326 |
|
327 def _create_missing_dirs(issues_path, issue): |
|
328 for d in maildir_dirs: |
|
329 path = os.path.join(issues_path,issue,d) |
|
330 if not os.path.exists(path): os.mkdir(path) |
|
331 |
|
332 def _create_all_missing_dirs(issues_path, issues): |
|
333 for i in issues: |
|
334 _create_missing_dirs(issues_path, i) |
|
335 |
|
336 def _humanreadable(size): |
|
337 if size > 1024*1024: |
|
338 return '%5.1fM' % (float(size) / (1024*1024)) |
|
339 elif size > 1024: |
|
340 return '%5.1fK' % (float(size) / 1024) |
|
341 else: |
|
342 return '%dB' % size |
|
343 |
|
344 def _attach_files(msg, filenames): |
|
345 outer = MIMEMultipart() |
|
346 for k in msg.keys(): outer[k] = msg[k] |
|
347 outer.attach(MIMEText(msg.get_payload())) |
|
348 |
|
349 for filename in filenames: |
|
350 ctype, encoding = mimetypes.guess_type(filename) |
|
351 if ctype is None or encoding is not None: |
|
352 # No guess could be made, or the file is encoded (compressed), so |
|
353 # use a generic bag-of-bits type. |
|
354 ctype = 'application/octet-stream' |
|
355 maintype, subtype = ctype.split('/', 1) |
|
356 if maintype == 'text': |
|
357 fp = open(filename) |
|
358 # Note: we should handle calculating the charset |
|
359 attachment = MIMEText(fp.read(), _subtype=subtype) |
|
360 fp.close() |
|
361 elif maintype == 'image': |
|
362 fp = open(filename, 'rb') |
|
363 attachment = MIMEImage(fp.read(), _subtype=subtype) |
|
364 fp.close() |
|
365 elif maintype == 'audio': |
|
366 fp = open(filename, 'rb') |
|
367 attachment = MIMEAudio(fp.read(), _subtype=subtype) |
|
368 fp.close() |
|
369 else: |
|
370 fp = open(filename, 'rb') |
|
371 attachment = MIMEBase(maintype, subtype) |
|
372 attachment.set_payload(fp.read()) |
|
373 fp.close() |
|
374 # Encode the payload using Base64 |
|
375 encoders.encode_base64(attachment) |
|
376 # Set the filename parameter |
|
377 attachment.add_header('Content-Disposition', 'attachment', filename=filename) |
|
378 outer.attach(attachment) |
|
379 return outer |
300 |
380 |
301 cmdtable = { |
381 cmdtable = { |
302 'ilist': (ilist, |
382 'ilist': (ilist, |
303 [('a', 'all', False, |
383 [('a', 'all', False, |
304 'list all issues (by default only those with state new)'), |
384 'list all issues (by default only those with state new)'), |
306 'list issues with specific field values (e.g., -p state=fixed)'), |
386 'list issues with specific field values (e.g., -p state=fixed)'), |
307 ('d', 'date', '', 'restrict to issues matching the date (e.g., -d ">12/28/2007)"'), |
387 ('d', 'date', '', 'restrict to issues matching the date (e.g., -d ">12/28/2007)"'), |
308 ('f', 'filter', '', 'restrict to pre-defined filter (in %s/%s*)' % (issues_dir, filter_prefix))], |
388 ('f', 'filter', '', 'restrict to pre-defined filter (in %s/%s*)' % (issues_dir, filter_prefix))], |
309 _('hg ilist [OPTIONS]')), |
389 _('hg ilist [OPTIONS]')), |
310 'iadd': (iadd, |
390 'iadd': (iadd, |
311 [], |
391 [('a', 'attach', [], |
|
392 'attach file(s) (e.g., -a filename1 -a filename2)')], |
312 _('hg iadd [ID] [COMMENT]')), |
393 _('hg iadd [ID] [COMMENT]')), |
313 'ishow': (ishow, |
394 'ishow': (ishow, |
314 [('a', 'all', None, 'list all comments')], |
395 [('a', 'all', None, 'list all comments'), |
|
396 ('x', 'extract', [], 'extract attachments')], |
315 _('hg ishow [OPTIONS] ID [COMMENT]')), |
397 _('hg ishow [OPTIONS] ID [COMMENT]')), |
316 'iupdate': (iupdate, |
398 'iupdate': (iupdate, |
317 [('p', 'property', [], |
399 [('p', 'property', [], |
318 'update properties (e.g., -p state=fixed)'), |
400 'update properties (e.g., -p state=fixed)'), |
319 ('n', 'no-property-comment', None, |
401 ('n', 'no-property-comment', None, |