artemis/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, 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