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