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