artemis.py
changeset 26 4574d2d34009
parent 24 17a8293bbbbf
child 27 6ab60ee8b151
equal deleted inserted replaced
25:a13239888b62 26:4574d2d34009
     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,