Merged in tzeman's issue #a5d
authorDmitriy Morozov <dmitriy@mrzv.org>
Thu, 28 Apr 2011 08:32:55 -0700
changeset 67 50ecb341130d
parent 65 30bd40ef9165 (diff)
parent 66 bf3b55a54cff (current diff)
child 68 88c088722a30
Merged in tzeman's issue #a5d
--- a/.issues/82aa4838dbeb6254/new/1259359374.M75010P29785Q1.vlan-laptop	Thu Jun 10 08:34:23 2010 +0200
+++ b/.issues/82aa4838dbeb6254/new/1259359374.M75010P29785Q1.vlan-laptop	Thu Apr 28 08:32:55 2011 -0700
@@ -1,8 +1,9 @@
 From: Andrey Vlasovskikh <andrey.vlasovskikh@gmail.com>
 Date: Sat, 28 Nov 2009 00:45:23
-State: new
+State: resolved
 Subject: Results of ilist appear to be unsorted
 Message-Id: <82aa4838dbeb6254-0-artemis@vlan-laptop>
+resolution: fixed
 
 I guess the results of `hg ilist` are listed in the same order as issues'
 directory names returned by `glob.glob`.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/82aa4838dbeb6254/new/1302928546.M969937P4447Q1.vine	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,7 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Fri, 15 Apr 2011 21:35:46 -0700
+Subject: changed properties (state=resolved, resolution=fixed)
+Message-Id: <82aa4838dbeb6254-9089359c47792ef9-artemis@vine>
+References: <82aa4838dbeb6254-0-artemis@vlan-laptop>
+In-Reply-To: <82aa4838dbeb6254-0-artemis@vlan-laptop>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/af5b63a7d86cf9e3/new/1303167129.M622016P25911Q1.vine	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,25 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Mon, 18 Apr 2011 15:41:53 -0700
+State: resolved
+Subject: Configurable format
+Message-Id: <af5b63a7d86cf9e3-0-artemis@vine>
+resolution: fixed
+
+The user should be able to define the format for the issue summary line (in
+ilist) in the config file. So, for instance, one could specify:
+
+  [artemis]
+  format = %(id)s (%(len)3d) [%(state)s]: %(Subject)s
+
+to have the default (current) format. One should also be able to specify
+additional conditions with the flags in the name of the option. For example:
+
+  format:state=in-progress    = %(id)s (%(len)3d) [%(state)s; priority=%(priority)s]: %(Subject)s
+  format:state=resolved       = %(id)s (%(len)3d) [%(state)s=%(resolution)s]: %(Subject)s
+  format:state=resolved&resolution=fixed  = %(id)s (%(len)3d) [fixed]: %(Subject)s    # also change color
+  format:state=fixed          = ...  # change color
+
+The format string would be passed the headers from the root message, so that
+the user could use whatever properties she wanted.
+
+This approach would also replace the current way we define colors.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/af5b63a7d86cf9e3/new/1303177038.M916213P1224Q1.vine	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,7 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Mon, 18 Apr 2011 18:37:18 -0700
+Subject: changed properties (state=in-progress)
+Message-Id: <af5b63a7d86cf9e3-eebc359fa41d3fb5-artemis@vine>
+References: <af5b63a7d86cf9e3-0-artemis@vine>
+In-Reply-To: <af5b63a7d86cf9e3-0-artemis@vine>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/af5b63a7d86cf9e3/new/1303189206.M66056P5702Q1.vine	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,11 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Mon, 18 Apr 2011 22:00:05 -0700
+Subject: Fixed (with changes in the format)
+Message-Id: <af5b63a7d86cf9e3-80e747b5d9f0b2ed-artemis@vine>
+References: <af5b63a7d86cf9e3-0-artemis@vine>
+In-Reply-To: <af5b63a7d86cf9e3-0-artemis@vine>
+
+Equal sign wouldn't work in the config file variable name, so instead the format
+is:
+
+  format:property1*value1&propert2*value2 = ...
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/c257b7f62fae9087/new/1302933431.M193154P8736Q1.vine	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,19 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Fri, 15 Apr 2011 22:54:20 -0700
+State: resolved
+Subject: State annotations
+Message-Id: <c257b7f62fae9087-0-artemis@vine>
+resolution: wontfix
+
+Make state annotations more generic. Right now, only "resolved" state lists
+the contents of the property resolution, i.e.
+
+  State: resolved
+  resolution: fixed
+
+shows up as
+
+  ... [resolved=fixed] ...
+
+in the issue list. The user should be able to configure this behavior for other
+states as well.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/c257b7f62fae9087/new/1303189598.M796344P5917Q1.vine	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,9 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Mon, 18 Apr 2011 22:04:40 -0700
+Subject: Irrelevant after #af5
+Message-Id: <c257b7f62fae9087-cdca9c416bd790fa-artemis@vine>
+References: <c257b7f62fae9087-0-artemis@vine>
+In-Reply-To: <c257b7f62fae9087-0-artemis@vine>
+
+The fix to #af5 makes this issue irrelevant. Annotations are too specialized
+and redundant once the summary format can be set in the config file.
--- a/.issues/cf0d1e2ca226848d/new/1268012009.M102732P9773Q1.vlan-laptop	Thu Jun 10 08:34:23 2010 +0200
+++ b/.issues/cf0d1e2ca226848d/new/1268012009.M102732P9773Q1.vlan-laptop	Thu Apr 28 08:32:55 2011 -0700
@@ -1,8 +1,9 @@
 From: Andrey Vlasovskikh <andrey.vlasovskikh@gmail.com>
 Date: Mon, 08 Mar 2010 01:23:25 +0300
-State: new
+State: resolved
 Subject: KeyError while accessing an empty Maildir
 Message-Id: <cf0d1e2ca226848d-0-artemis@vlan-laptop>
+resolution: fixed
 
 If a Maildir contains no message files, `hg ilist` raises the following
 exception:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/cf0d1e2ca226848d/new/1289846722.M853744P9554Q1.cole	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,8 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Mon, 15 Nov 2010 10:44:57 -0800
+Subject: ilist ignores empty Maildirs
+Message-Id: <cf0d1e2ca226848d-c95c687999e65b87-artemis@cole>
+References: <cf0d1e2ca226848d-0-artemis@vlan-laptop>
+In-Reply-To: <cf0d1e2ca226848d-0-artemis@vlan-laptop>
+
+If _find_root_key() returns None on a directory, ilist will ignore it.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/e7c56580805d0cbb/new/1302052484.M197392P8518Q1.vine	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,11 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Tue, 05 Apr 2011 18:12:28 -0700
+State: new
+Subject: Add --import option to iadd
+Message-Id: <e7c56580805d0cbb-0-artemis@vine>
+
+It should be possible to import an email message (either from a file or stdin)
+by passing --import option to iadd. If no ID is given, treat the email as a new
+bug (i.e. just generate a random id); if an ID is given, append the message to
+that Maildir. Need to decide what to do if the message does not fit into the
+hierarchy in the latter case.
--- a/.issues/edb1a7697e0f1e24/new/1208863350.M507651P11810Q1.metatron	Thu Jun 10 08:34:23 2010 +0200
+++ b/.issues/edb1a7697e0f1e24/new/1208863350.M507651P11810Q1.metatron	Thu Apr 28 08:32:55 2011 -0700
@@ -1,7 +1,8 @@
 From: Dmitriy Morozov <morozov@cs.duke.edu>
 Date: Tue, 22 Apr 2008 07:21:57
-State: new
+State: resolved
 Subject: Colorize output
 Message-Id: <edb1a7697e0f1e24-0-artemis@metatron>
+resolution: fixed
 
 Allow one to colorize output. Naturally with user-customizable colors.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/edb1a7697e0f1e24/new/1302932456.M373542P7817Q1.vine	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,11 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Fri, 15 Apr 2011 22:35:57 -0700
+Subject: More generic color selection
+Message-Id: <edb1a7697e0f1e24-b3d7d7d90300e66a-artemis@vine>
+References: <edb1a7697e0f1e24-0-artemis@metatron>
+In-Reply-To: <edb1a7697e0f1e24-0-artemis@metatron>
+
+Right now the color criteria are hard-coded: state is new vs fixed.  This
+selection should be more generic. Instead of providing just two possible color
+styles in the config file, the user should be able to specify colors for
+arbitrary states, maybe even more general selection based on other properties.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/edb1a7697e0f1e24/new/1302979699.M207885P16676Q1.vine	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,7 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Sat, 16 Apr 2011 11:48:19 -0700
+Subject: changed properties (state=resolved, resolution=fixed)
+Message-Id: <edb1a7697e0f1e24-f1d9470cb8b86647-artemis@vine>
+References: <edb1a7697e0f1e24-0-artemis@metatron>
+In-Reply-To: <edb1a7697e0f1e24-0-artemis@metatron>
+
--- a/.issues/f09aeee4b1552679/new/1259361432.M651088P97096Q1.gto.home	Thu Jun 10 08:34:23 2010 +0200
+++ b/.issues/f09aeee4b1552679/new/1259361432.M651088P97096Q1.gto.home	Thu Apr 28 08:32:55 2011 -0700
@@ -1,8 +1,9 @@
 From: Alexander Solovyov <piranha@piranha.org.ua>
 Date: Sat, 28 Nov 2009 00:33:33
-State: new
+State: resolved
 Subject: iadd should commit on finish
 Message-Id: <f09aeee4b1552679-0-artemis@gto.home>
+resolution: fixed
 
 Actually there is two possibilities - one is to leave behavior as is and just
 add '--commit' option, and another is to commit after finishing and add
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.issues/f09aeee4b1552679/new/1289846301.M956270P2257Q1.cole	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,9 @@
+From: Dmitriy Morozov <dmitriy@mrzv.org>
+Date: Mon, 15 Nov 2010 10:37:43 -0800
+Subject: Added --commit option to iadd
+Message-Id: <f09aeee4b1552679-bd15fe2a81404d2e-artemis@cole>
+References: <f09aeee4b1552679-0-artemis@gto.home>
+In-Reply-To: <f09aeee4b1552679-0-artemis@gto.home>
+
+The user can explicitly request iadd to commit the entire issue after iadd
+finishes.
--- a/README	Thu Jun 10 08:34:23 2010 +0200
+++ b/README	Thu Apr 28 08:32:55 2011 -0700
@@ -26,8 +26,8 @@
 -----
 
 In the ``[extensions]`` section of your ``~/.hgrc`` add::
-    
-    artemis = /path/to/artemis.py
+
+    artemis = /path/to/Artemis/artemis
 
 Optionally, provide a section ``[artemis]``, and specify an alternative path for
 the issues subdirectory (instead of the default ``.issues``)::
@@ -35,6 +35,9 @@
     [artemis]
     issues = _issues
 
+Additionally, one can specify filters_ and output formats_.
+
+.. _formats:     Format_
 
 Example
 -------
@@ -43,7 +46,7 @@
 
     # hg iadd
     ... enter some text in an editor ...
-    Added new issue 907ab57e04502afd 
+    Added new issue 907ab57e04502afd
 
     # hg ilist
     907ab57e04502afd (  0) [new]: New issue
@@ -56,18 +59,18 @@
     State: new
 
     Detailed description.
-    
+
     ----------------------------------------------------------------------
 
 Add a comment to the issue::
-    
+
     # hg iadd 907
     ... enter the comment text
     ======================================================================
     From: ...
     [snip]
     Detailed description.
-    
+
     ----------------------------------------------------------------------
     Comments:
       1: [dmitriy] Some comment
@@ -81,7 +84,7 @@
     From: ...
     [snip]
     Detailed description.
-    
+
     ----------------------------------------------------------------------
     Comments:
       1: [dmitriy] Some comment
@@ -95,7 +98,7 @@
     From: ...
     [snip]
     Detailed description.
-    
+
     ----------------------------------------------------------------------
     Comments:
       1: [dmitriy] Some comment
@@ -122,7 +125,7 @@
     recorded as a reply to the particular message. `iadd` is the only command
     that changes the state of the repository (by adding the new issue files to
     the list of tracked files or updating some of them), however, it does not
-    perform an actual commit.
+    perform an actual commit unless explicitly asked to do so.
 
     `-p`, `--property`
         update a property of the issue ``ID``, e.g. ``-p state=resolved -p resolution=fixed``
@@ -137,6 +140,10 @@
     `-m`, `--message`
         use ``text`` as an issue subject
 
+    `-c`, `--commit`
+        commit the issue after the addition (all changes to the issue will be
+        committed)
+
 
 `ilist`
     List issues.
@@ -145,18 +152,22 @@
         list all issues (not just the `new` ones)
 
     `-p`, `--property`
-        list issues with specific property values, e.g. 
+        list issues with specific property values, e.g.
         ``-p state=resolved -p category=documentation``;
         if no property value is provided (e.g. ``-p category``), lists all
         possible values for that property (among the issues that satisfy the
         rest of the criteria)
 
+    `-o`, `--order`
+        order of the issues; choices: "new" (date submitted), "latest" (date of
+        the most recent message)
+
     `-d`, `--date`
         restrict to issues matching the given date, e.g. ``-d ">1/1/2008"``
 
     `-f`, `--filter`
         restrict to a predefined filter, see Filters_ below
-   
+
 
 `ishow` ``[ID] [COMMENT]``
     Show an issue or a comment.
@@ -191,3 +202,34 @@
 invoked with the `ilist` command::
 
     hg ilist -f olddoc
+
+
+Format
+------
+
+One can specify the output format for the `ilist` command. The default looks
+like::
+
+    [artemis]
+    format = %(id)s (%(len)3d) [%(state)s]: %(subject)s
+
+Artemis passes a dictionary with the issue properties to the format string.
+(Plus ``id`` contains the issue id, and ``len`` contains the number of replies.)
+
+It's possible to specify different output formats depending on the properties of
+the issue. The conditions are encoded in the config variable names as follows::
+
+    format:state*resolved&resolution*fixed  = %(id)s (%(len)3d) [fixed]: %(Subject)s
+    format:state*resolved                   = %(id)s (%(len)3d) [%(state)s=%(resolution)s]: %(Subject)s
+
+The first rule matches issues with the ``state`` property set to ``resolved``
+and ``resolution`` set to ``fixed``; it abridges the output. The secod rule
+matches all the ``resolved`` issues (not matched by the first rule); it annotates
+the issue's state with its ``resolution``.
+
+Finally, the dictionary passed to the format string contains a subset of
+`ANSI codes`_, so one could color the summary lines::
+
+    format:state*new = %(red)s%(bold)s%(id)s (%(len)3d) [%(state)s]: %(Subject)s%(reset)s
+
+.. _`ANSI codes`:       http://en.wikipedia.org/wiki/ANSI_escape_code
--- a/artemis.py	Thu Jun 10 08:34:23 2010 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,428 +0,0 @@
-# Author: Dmitriy Morozov <hg@foxcub.org>, 2007 -- 2009
-
-"""A very simple and lightweight issue tracker for Mercurial."""
-
-from mercurial import hg, util
-from mercurial.i18n import _
-import os, time, random, mailbox, glob, socket, ConfigParser
-import mimetypes
-from email import encoders
-from email.generator import Generator
-from email.mime.audio import MIMEAudio
-from email.mime.base import MIMEBase
-from email.mime.image import MIMEImage
-from email.mime.multipart import MIMEMultipart
-from email.mime.text import MIMEText
-
-
-state = {'new': 'new', 'fixed': ['fixed', 'resolved']}
-state['default'] = state['new']
-default_issues_dir = ".issues"
-filter_prefix = ".filter"
-date_format = '%a, %d %b %Y %H:%M:%S %1%2'
-maildir_dirs = ['new','cur','tmp']
-
-
-def ilist(ui, repo, **opts):
-    """List issues associated with the project"""
-
-    # Process options
-    show_all = opts['all']
-    properties = []
-    match_date, date_match = False, lambda x: True
-    if opts['date']:
-        match_date, date_match = True, util.matchdate(opts['date'])
-
-    # Find issues
-    issues_dir = ui.config('artemis', 'issues', default = default_issues_dir)
-    issues_path = os.path.join(repo.root, issues_dir)
-    if not os.path.exists(issues_path): return
-
-    issues = glob.glob(os.path.join(issues_path, '*'))
-
-    _create_all_missing_dirs(issues_path, issues)
-
-    # Process filter
-    if opts['filter']:
-        filters = glob.glob(os.path.join(issues_path, filter_prefix + '*'))
-        config = ConfigParser.SafeConfigParser()
-        config.read(filters)
-        if not config.has_section(opts['filter']):
-            ui.write('No filter %s defined\n' % opts['filter'])
-        else:
-            properties += config.items(opts['filter'])
-
-    cmd_properties = _get_properties(opts['property'])
-    list_properties = [p[0] for p in cmd_properties if len(p) == 1]
-    list_properties_dict = {}
-    properties += filter(lambda p: len(p) > 1, cmd_properties)
-
-    for issue in issues:
-        mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage)
-        root = _find_root_key(mbox)
-        property_match = True
-        for property,value in properties:
-            if value:
-                property_match = property_match and (mbox[root][property] == value)
-            else:
-                property_match = property_match and (property not in mbox[root])
-
-        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
-        if match_date and not date_match(util.parsedate(mbox[root]['date'])[0]): continue
-
-        if not list_properties:
-            ui.write("%s (%3d) [%s]: %s\n" % (issue[len(issues_path)+1:], # +1 for trailing /
-                                              len(mbox)-1,                # number of replies (-1 for self)
-                                              _status_msg(mbox[root]),
-                                              mbox[root]['Subject']))
-        else:
-            for lp in list_properties:
-                if lp in mbox[root]:    list_properties_dict.setdefault(lp, set()).add(mbox[root][lp])
-
-    if list_properties:
-        for lp in list_properties_dict.keys():
-            ui.write("%s:\n" % lp)
-            for value in sorted(list_properties_dict[lp]):
-                ui.write("  %s\n" % value)
-
-
-def iadd(ui, repo, id = None, comment = 0, **opts):
-    """Adds a new issue, or comment to an existing issue ID or its comment COMMENT"""
-
-    comment = int(comment)
-
-    # First, make sure issues have a directory
-    issues_dir = ui.config('artemis', 'issues', default = default_issues_dir)
-    issues_path = os.path.join(repo.root, issues_dir)
-    if not os.path.exists(issues_path): os.mkdir(issues_path)
-
-    if id:
-        issue_fn, issue_id = _find_issue(ui, repo, id)
-        if not issue_fn:
-            ui.warn('No such issue\n')
-            return
-        _create_missing_dirs(issues_path, issue_id)
-
-    user = ui.username()
-
-    default_issue_text  =         "From: %s\nDate: %s\n" % (user, util.datestr(format = date_format))
-    if not id:
-        default_issue_text +=     "State: %s\n" % state['default']
-    default_issue_text +=         "Subject: brief description\n\n"
-    default_issue_text +=         "Detailed description."
-
-    # Get properties, and figure out if we need an explicit comment
-    properties = _get_properties(opts['property'])
-    no_comment = id and properties and opts['no_property_comment']
-    message = opts['message']
-
-    # Create the text
-    if message:
-        if not id:
-            state_str = 'State: %s\n' % state['default']
-        else:
-            state_str = ''
-        issue = "From: %s\nDate: %s\nSubject: %s\n%s" % \
-                (user, util.datestr(format=date_format), message, state_str)
-    elif not no_comment:
-        issue = ui.edit(default_issue_text, user)
-
-        if issue.strip() == '':
-            ui.warn('Empty issue, ignoring\n')
-            return
-        if issue.strip() == default_issue_text:
-            ui.warn('Unchanged issue text, ignoring\n')
-            return
-    else:
-        # Write down a comment about updated properties
-        properties_subject = ', '.join(['%s=%s' % (property, value) for (property, value) in properties])
-
-        issue =     "From: %s\nDate: %s\nSubject: changed properties (%s)\n" % \
-                     (user, util.datestr(format = date_format), properties_subject)
-
-    # Create the message
-    msg = mailbox.MaildirMessage(issue)
-    if opts['attach']:
-        outer = _attach_files(msg, opts['attach'])
-    else:
-        outer = msg
-
-    # Pick random filename
-    if not id:
-        issue_fn = issues_path
-        while os.path.exists(issue_fn):
-            issue_id = _random_id()
-            issue_fn = os.path.join(issues_path, issue_id)
-    # else: issue_fn already set
-
-    # Add message to the mailbox
-    mbox = mailbox.Maildir(issue_fn, factory=mailbox.MaildirMessage)
-    keys = _order_keys_date(mbox)
-    mbox.lock()
-    if id and comment >= len(mbox):
-        ui.warn('No such comment number in mailbox, commenting on the issue itself\n')
-
-    if not id:
-        outer.add_header('Message-Id', "<%s-0-artemis@%s>" % (issue_id, socket.gethostname()))
-    else:
-        root = keys[0]
-        outer.add_header('Message-Id', "<%s-%s-artemis@%s>" % (issue_id, _random_id(), socket.gethostname()))
-        outer.add_header('References', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id'])
-        outer.add_header('In-Reply-To', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id'])
-    repo.add([issue_fn[(len(repo.root)+1):] + '/new/'  + mbox.add(outer)])   # +1 for the trailing /
-
-    # Fix properties in the root message
-    if properties:
-        root = _find_root_key(mbox)
-        msg = mbox[root]
-        for property, value in properties:
-            if property in msg:
-                msg.replace_header(property, value)
-            else:
-                msg.add_header(property, value)
-        mbox[root] = msg
-
-    mbox.close()
-
-    # If adding issue, add the new mailbox to the repository
-    if not id:
-        ui.status('Added new issue %s\n' % issue_id)
-    else:
-        _show_mbox(ui, mbox, 0)
-
-def ishow(ui, repo, id, comment = 0, **opts):
-    """Shows issue ID, or possibly its comment COMMENT"""
-
-    comment = int(comment)
-    issue, id = _find_issue(ui, repo, id)
-    if not issue:
-        return ui.warn('No such issue\n')
-    
-    issues_dir = ui.config('artemis', 'issues', default = default_issues_dir)
-    _create_missing_dirs(os.path.join(repo.root, issues_dir), issue)
-
-    if opts.get('mutt'):
-        return util.system('mutt -R -f %s' % issue)
-
-    mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage)
-
-    if opts['all']:
-        ui.write('='*70 + '\n')
-        i = 0
-        keys = _order_keys_date(mbox)
-        for k in keys:
-            _write_message(ui, mbox[k], i, skip = opts['skip'])
-            ui.write('-'*70 + '\n')
-            i += 1
-        return
-
-    _show_mbox(ui, mbox, comment, skip = opts['skip'])
-
-    if opts['extract']:
-        attachment_numbers = map(int, opts['extract'])
-        keys = _order_keys_date(mbox)
-        msg = mbox[keys[comment]]
-        counter = 1
-        for part in msg.walk():
-            ctype = part.get_content_type()
-            maintype, subtype = ctype.split('/', 1)
-            if maintype == 'multipart' or ctype == 'text/plain': continue
-            if counter in attachment_numbers:
-                filename = part.get_filename()
-                if not filename:
-                    ext = mimetypes.guess_extension(part.get_content_type()) or ''
-                    filename = 'attachment-%03d%s' % (counter, ext)
-                fp = open(filename, 'wb')
-                fp.write(part.get_payload(decode = True))
-                fp.close()
-            counter += 1
-
-
-def _find_issue(ui, repo, id):
-    issues_dir = ui.config('artemis', 'issues', default = default_issues_dir)
-    issues_path = os.path.join(repo.root, issues_dir)
-    if not os.path.exists(issues_path): return False
-
-    issues = glob.glob(os.path.join(issues_path, id + '*'))
-
-    if len(issues) == 0:
-        return False, 0
-    elif len(issues) > 1:
-        ui.status("Multiple choices:\n")
-        for i in issues: ui.status('  ', i[len(issues_path)+1:], '\n')
-        return False, 0
-
-    return issues[0], issues[0][len(issues_path)+1:]
-
-def _get_properties(property_list):
-    return [p.split('=') for p in property_list]
-
-def _write_message(ui, message, index = 0, skip = None):
-    if index: ui.write("Comment: %d\n" % index)
-    if ui.verbose:
-        _show_text(ui, message.as_string().strip(), skip)
-    else:
-        if 'From' in message: ui.write('From: %s\n' % message['From'])
-        if 'Date' in message: ui.write('Date: %s\n' % message['Date'])
-        if 'Subject' in message: ui.write('Subject: %s\n' % message['Subject'])
-        if 'State' in message: ui.write('State: %s\n' % message['State'])
-        counter = 1
-        for part in message.walk():
-            ctype = part.get_content_type()
-            maintype, subtype = ctype.split('/', 1)
-            if maintype == 'multipart': continue
-            if ctype == 'text/plain':
-                ui.write('\n')
-                _show_text(ui, part.get_payload().strip(), skip)
-            else:
-                filename = part.get_filename()
-                ui.write('\n' + '%d: Attachment [%s, %s]: %s' % (counter, ctype, _humanreadable(len(part.get_payload())), filename) + '\n')
-                counter += 1
-
-def _show_text(ui, text, skip = None):
-    for line in text.splitlines():
-        if not skip or not line.startswith(skip):
-            ui.write(line + '\n')
-    ui.write('\n')
-
-def _show_mbox(ui, mbox, comment, **opts):
-    # Output the issue (or comment)
-    if comment >= len(mbox):
-        comment = 0
-        ui.warn('Comment out of range, showing the issue itself\n')
-    keys = _order_keys_date(mbox)
-    root = keys[0]
-    msg = mbox[keys[comment]]
-    ui.write('='*70 + '\n')
-    if comment:
-        ui.write('Subject: %s\n' % mbox[root]['Subject'])
-        ui.write('State: %s\n' % mbox[root]['State'])
-        ui.write('-'*70 + '\n')
-    _write_message(ui, msg, comment, skip = ('skip' in opts) and opts['skip'])
-    ui.write('-'*70 + '\n')
-
-    # Read the mailbox into the messages and children dictionaries
-    messages = {}
-    children = {}
-    i = 0
-    for k in keys:
-        m = mbox[k]
-        messages[m['Message-Id']] = (i,m)
-        children.setdefault(m['In-Reply-To'], []).append(m['Message-Id'])
-        i += 1
-    children[None] = []                # Safeguard against infinte loop on empty Message-Id
-
-    # Iterate over children
-    id = msg['Message-Id']
-    id_stack = (id in children and map(lambda x: (x, 1), reversed(children[id]))) or []
-    if not id_stack: return
-    ui.write('Comments:\n')
-    while id_stack:
-        id,offset = id_stack.pop()
-        id_stack += (id in children and map(lambda x: (x, offset+1), reversed(children[id]))) or []
-        index, msg = messages[id]
-        ui.write('  '*offset + '%d: [%s] %s\n' % (index, util.shortuser(msg['From']), msg['Subject']))
-    ui.write('-'*70 + '\n')
-
-def _find_root_key(maildir):
-    for k,m in maildir.iteritems():
-        if 'in-reply-to' not in m:
-            return k
-
-def _order_keys_date(mbox):
-    keys = mbox.keys()
-    root = _find_root_key(mbox)
-    keys.sort(lambda k1,k2: -(k1 == root) or cmp(util.parsedate(mbox[k1]['date']), util.parsedate(mbox[k2]['date'])))
-    return keys
-
-def _random_id():
-    return "%x" % random.randint(2**63, 2**64-1)
-
-def _create_missing_dirs(issues_path, issue):
-    for d in maildir_dirs:
-        path = os.path.join(issues_path,issue,d)
-        if not os.path.exists(path): os.mkdir(path)
-
-def _create_all_missing_dirs(issues_path, issues):
-    for i in issues:
-        _create_missing_dirs(issues_path, i)
-
-def _humanreadable(size):
-    if size > 1024*1024:
-        return '%5.1fM' % (float(size) / (1024*1024))
-    elif size > 1024:
-        return '%5.1fK' % (float(size) / 1024)
-    else:
-        return '%dB' % size
-
-def _attach_files(msg, filenames):
-    outer = MIMEMultipart()
-    for k in msg.keys(): outer[k] = msg[k]
-    outer.attach(MIMEText(msg.get_payload()))
-
-    for filename in filenames:
-        ctype, encoding = mimetypes.guess_type(filename)
-        if ctype is None or encoding is not None:
-            # No guess could be made, or the file is encoded (compressed), so
-            # use a generic bag-of-bits type.
-            ctype = 'application/octet-stream'
-        maintype, subtype = ctype.split('/', 1)
-        if maintype == 'text':
-            fp = open(filename)
-            # Note: we should handle calculating the charset
-            attachment = MIMEText(fp.read(), _subtype=subtype)
-            fp.close()
-        elif maintype == 'image':
-            fp = open(filename, 'rb')
-            attachment = MIMEImage(fp.read(), _subtype=subtype)
-            fp.close()
-        elif maintype == 'audio':
-            fp = open(filename, 'rb')
-            attachment = MIMEAudio(fp.read(), _subtype=subtype)
-            fp.close()
-        else:
-            fp = open(filename, 'rb')
-            attachment = MIMEBase(maintype, subtype)
-            attachment.set_payload(fp.read())
-            fp.close()
-            # Encode the payload using Base64
-            encoders.encode_base64(attachment)
-        # Set the filename parameter
-        attachment.add_header('Content-Disposition', 'attachment', filename=filename)
-        outer.attach(attachment)
-    return outer
-
-def _status_msg(msg):
-    if msg['State'] == 'resolved':
-        return 'resolved=' + msg['resolution']
-    else:
-        return msg['State']
-
-cmdtable = {
-    'ilist':    (ilist,
-                 [('a', 'all', False,
-                   'list all issues (by default only those with state new)'),
-                  ('p', 'property', [],
-                   'list issues with specific field values (e.g., -p state=fixed); lists all possible values of a property if no = sign'),
-                  ('d', 'date', '', 'restrict to issues matching the date (e.g., -d ">12/28/2007)"'),
-                  ('f', 'filter', '', 'restrict to pre-defined filter (in %s/%s*)' % (default_issues_dir, filter_prefix))],
-                 _('hg ilist [OPTIONS]')),
-    'iadd':       (iadd,
-                 [('a', 'attach', [],
-                   'attach file(s) (e.g., -a filename1 -a filename2)'),
-                  ('p', 'property', [],
-                   'update properties (e.g., -p state=fixed)'),
-                  ('n', 'no-property-comment', None,
-                   'do not add a comment about changed properties'),
-                  ('m', 'message', '',
-                   'use <text> as an issue subject')],
-                 _('hg iadd [OPTIONS] [ID] [COMMENT]')),
-    'ishow':      (ishow,
-                 [('a', 'all', None, 'list all comments'),
-                  ('s', 'skip', '>', 'skip lines starting with a substring'),
-                  ('x', 'extract', [], 'extract attachments (provide attachment number as argument)'),
-                  ('', 'mutt', False, 'use mutt to show issue')],
-                 _('hg ishow [OPTIONS] ID [COMMENT]')),
-}
-
-# vim: expandtab
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/artemis/__init__.py	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,1 @@
+from artemis import *
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/artemis/artemis.py	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,511 @@
+# Author: Dmitriy Morozov <hg@foxcub.org>, 2007 -- 2009
+
+"""A very simple and lightweight issue tracker for Mercurial."""
+
+from mercurial import hg, util, commands
+from mercurial.i18n import _
+import os, time, random, mailbox, glob, socket, ConfigParser
+import mimetypes
+from email import encoders
+from email.generator import Generator
+from email.mime.audio import MIMEAudio
+from email.mime.base import MIMEBase
+from email.mime.image import MIMEImage
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from itertools import izip
+
+
+state = { 'new':      ['new'],
+          'resolved': ['fixed', 'resolved'] }
+default_state = 'new'
+default_issues_dir = ".issues"
+filter_prefix = ".filter"
+date_format = '%a, %d %b %Y %H:%M:%S %1%2'
+maildir_dirs = ['new','cur','tmp']
+default_format = '%(id)s (%(len)3d) [%(state)s]: %(Subject)s'
+
+def ilist(ui, repo, **opts):
+    """List issues associated with the project"""
+
+    # Process options
+    show_all = opts['all']
+    properties = []
+    match_date, date_match = False, lambda x: True
+    if opts['date']:
+        match_date, date_match = True, util.matchdate(opts['date'])
+    order = 'new'
+    if opts['order']:
+        order = opts['order']
+
+    # Formats
+    formats = _read_formats(ui)
+
+    # Find issues
+    issues_dir = ui.config('artemis', 'issues', default = default_issues_dir)
+    issues_path = os.path.join(repo.root, issues_dir)
+    if not os.path.exists(issues_path): return
+
+    issues = glob.glob(os.path.join(issues_path, '*'))
+
+    _create_all_missing_dirs(issues_path, issues)
+
+    # Process filter
+    if opts['filter']:
+        filters = glob.glob(os.path.join(issues_path, filter_prefix + '*'))
+        config = ConfigParser.SafeConfigParser()
+        config.read(filters)
+        if not config.has_section(opts['filter']):
+            ui.write('No filter %s defined\n' % opts['filter'])
+        else:
+            properties += config.items(opts['filter'])
+
+    cmd_properties = _get_properties(opts['property'])
+    list_properties = [p[0] for p in cmd_properties if len(p) == 1]
+    list_properties_dict = {}
+    properties += filter(lambda p: len(p) > 1, cmd_properties)
+
+    summaries = []
+    for issue in issues:
+        mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage)
+        root = _find_root_key(mbox)
+        if not root: continue
+        property_match = True
+        for property,value in properties:
+            if value:
+                property_match = property_match and (mbox[root][property] == value)
+            else:
+                property_match = property_match and (property not in mbox[root])
+
+        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
+        if match_date and not date_match(util.parsedate(mbox[root]['date'])[0]): continue
+
+        if not list_properties:
+            summaries.append((_summary_line(mbox, root, issue[len(issues_path)+1:], formats),     # +1 for trailing /
+                              _find_mbox_date(mbox, root, order)))
+        else:
+            for lp in list_properties:
+                if lp in mbox[root]:    list_properties_dict.setdefault(lp, set()).add(mbox[root][lp])
+
+    if not list_properties:
+        summaries.sort(lambda (s1,d1),(s2,d2): cmp(d2,d1))
+        for s,d in summaries:
+            ui.write(s + '\n')
+    else:
+        for lp in list_properties_dict.keys():
+            ui.write("%s:\n" % lp)
+            for value in sorted(list_properties_dict[lp]):
+                ui.write("  %s\n" % value)
+
+
+def iadd(ui, repo, id = None, comment = 0, **opts):
+    """Adds a new issue, or comment to an existing issue ID or its comment COMMENT"""
+
+    comment = int(comment)
+
+    # First, make sure issues have a directory
+    issues_dir = ui.config('artemis', 'issues', default = default_issues_dir)
+    issues_path = os.path.join(repo.root, issues_dir)
+    if not os.path.exists(issues_path): os.mkdir(issues_path)
+
+    if id:
+        issue_fn, issue_id = _find_issue(ui, repo, id)
+        if not issue_fn:
+            ui.warn('No such issue\n')
+            return
+        _create_missing_dirs(issues_path, issue_id)
+        mbox = mailbox.Maildir(issue_fn, factory=mailbox.MaildirMessage)
+        keys = _order_keys_date(mbox)
+        root = keys[0]
+
+    user = ui.username()
+
+    default_issue_text  =         "From: %s\nDate: %s\n" % (user, util.datestr(format = date_format))
+    if not id:
+        default_issue_text +=     "State: %s\n" % default_state
+        default_issue_text +=     "Subject: brief description\n\n"
+    else:
+        subject = mbox[(comment < len(mbox) and keys[comment]) or root]['Subject']
+        if not subject.startswith('Re: '): subject = 'Re: ' + subject
+        default_issue_text +=     "Subject: %s\n\n" % subject
+    default_issue_text +=         "Detailed description."
+
+    # Get properties, and figure out if we need an explicit comment
+    properties = _get_properties(opts['property'])
+    no_comment = id and properties and opts['no_property_comment']
+    message = opts['message']
+
+    # Create the text
+    if message:
+        if not id:
+            state_str = 'State: %s\n' % default_state
+        else:
+            state_str = ''
+        issue = "From: %s\nDate: %s\nSubject: %s\n%s" % \
+                (user, util.datestr(format=date_format), message, state_str)
+    elif not no_comment:
+        issue = ui.edit(default_issue_text, user)
+
+        if issue.strip() == '':
+            ui.warn('Empty issue, ignoring\n')
+            return
+        if issue.strip() == default_issue_text:
+            ui.warn('Unchanged issue text, ignoring\n')
+            return
+    else:
+        # Write down a comment about updated properties
+        properties_subject = ', '.join(['%s=%s' % (property, value) for (property, value) in properties])
+
+        issue =     "From: %s\nDate: %s\nSubject: changed properties (%s)\n" % \
+                     (user, util.datestr(format = date_format), properties_subject)
+
+    # Create the message
+    msg = mailbox.MaildirMessage(issue)
+    if opts['attach']:
+        outer = _attach_files(msg, opts['attach'])
+    else:
+        outer = msg
+
+    # Pick random filename
+    if not id:
+        issue_fn = issues_path
+        while os.path.exists(issue_fn):
+            issue_id = _random_id()
+            issue_fn = os.path.join(issues_path, issue_id)
+        mbox = mailbox.Maildir(issue_fn, factory=mailbox.MaildirMessage)
+        keys = _order_keys_date(mbox)
+    # else: issue_fn already set
+
+    # Add message to the mailbox
+    mbox.lock()
+    if id and comment >= len(mbox):
+        ui.warn('No such comment number in mailbox, commenting on the issue itself\n')
+
+    if not id:
+        outer.add_header('Message-Id', "<%s-0-artemis@%s>" % (issue_id, socket.gethostname()))
+    else:
+        root = keys[0]
+        outer.add_header('Message-Id', "<%s-%s-artemis@%s>" % (issue_id, _random_id(), socket.gethostname()))
+        outer.add_header('References', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id'])
+        outer.add_header('In-Reply-To', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id'])
+    new_bug_path = issue_fn[(len(repo.root)+1):] + '/new/' + mbox.add(outer) # + 1 for the trailing /
+    commands.add(ui, repo, new_bug_path)
+
+    # Fix properties in the root message
+    if properties:
+        root = _find_root_key(mbox)
+        msg = mbox[root]
+        for property, value in properties:
+            if property in msg:
+                msg.replace_header(property, value)
+            else:
+                msg.add_header(property, value)
+        mbox[root] = msg
+
+    mbox.close()
+
+    if opts['commit']:
+        commands.commit(ui, repo, issue_fn)
+
+    # If adding issue, add the new mailbox to the repository
+    if not id:
+        ui.status('Added new issue %s\n' % issue_id)
+    else:
+        _show_mbox(ui, mbox, 0)
+
+def ishow(ui, repo, id, comment = 0, **opts):
+    """Shows issue ID, or possibly its comment COMMENT"""
+
+    comment = int(comment)
+    issue, id = _find_issue(ui, repo, id)
+    if not issue:
+        return ui.warn('No such issue\n')
+
+    issues_dir = ui.config('artemis', 'issues', default = default_issues_dir)
+    _create_missing_dirs(os.path.join(repo.root, issues_dir), issue)
+
+    if opts.get('mutt'):
+        return util.system('mutt -R -f %s' % issue)
+
+    mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage)
+
+    if opts['all']:
+        ui.write('='*70 + '\n')
+        i = 0
+        keys = _order_keys_date(mbox)
+        for k in keys:
+            _write_message(ui, mbox[k], i, skip = opts['skip'])
+            ui.write('-'*70 + '\n')
+            i += 1
+        return
+
+    _show_mbox(ui, mbox, comment, skip = opts['skip'])
+
+    if opts['extract']:
+        attachment_numbers = map(int, opts['extract'])
+        keys = _order_keys_date(mbox)
+        msg = mbox[keys[comment]]
+        counter = 1
+        for part in msg.walk():
+            ctype = part.get_content_type()
+            maintype, subtype = ctype.split('/', 1)
+            if maintype == 'multipart' or ctype == 'text/plain': continue
+            if counter in attachment_numbers:
+                filename = part.get_filename()
+                if not filename:
+                    ext = mimetypes.guess_extension(part.get_content_type()) or ''
+                    filename = 'attachment-%03d%s' % (counter, ext)
+                fp = open(filename, 'wb')
+                fp.write(part.get_payload(decode = True))
+                fp.close()
+            counter += 1
+
+
+def _find_issue(ui, repo, id):
+    issues_dir = ui.config('artemis', 'issues', default = default_issues_dir)
+    issues_path = os.path.join(repo.root, issues_dir)
+    if not os.path.exists(issues_path): return False
+
+    issues = glob.glob(os.path.join(issues_path, id + '*'))
+
+    if len(issues) == 0:
+        return False, 0
+    elif len(issues) > 1:
+        ui.status("Multiple choices:\n")
+        for i in issues: ui.status('  ', i[len(issues_path)+1:], '\n')
+        return False, 0
+
+    return issues[0], issues[0][len(issues_path)+1:]
+
+def _get_properties(property_list):
+    return [p.split('=') for p in property_list]
+
+def _write_message(ui, message, index = 0, skip = None):
+    if index: ui.write("Comment: %d\n" % index)
+    if ui.verbose:
+        _show_text(ui, message.as_string().strip(), skip)
+    else:
+        if 'From' in message: ui.write('From: %s\n' % message['From'])
+        if 'Date' in message: ui.write('Date: %s\n' % message['Date'])
+        if 'Subject' in message: ui.write('Subject: %s\n' % message['Subject'])
+        if 'State' in message: ui.write('State: %s\n' % message['State'])
+        counter = 1
+        for part in message.walk():
+            ctype = part.get_content_type()
+            maintype, subtype = ctype.split('/', 1)
+            if maintype == 'multipart': continue
+            if ctype == 'text/plain':
+                ui.write('\n')
+                _show_text(ui, part.get_payload().strip(), skip)
+            else:
+                filename = part.get_filename()
+                ui.write('\n' + '%d: Attachment [%s, %s]: %s' % (counter, ctype, _humanreadable(len(part.get_payload())), filename) + '\n')
+                counter += 1
+
+def _show_text(ui, text, skip = None):
+    for line in text.splitlines():
+        if not skip or not line.startswith(skip):
+            ui.write(line + '\n')
+    ui.write('\n')
+
+def _show_mbox(ui, mbox, comment, **opts):
+    # Output the issue (or comment)
+    if comment >= len(mbox):
+        comment = 0
+        ui.warn('Comment out of range, showing the issue itself\n')
+    keys = _order_keys_date(mbox)
+    root = keys[0]
+    msg = mbox[keys[comment]]
+    ui.write('='*70 + '\n')
+    if comment:
+        ui.write('Subject: %s\n' % mbox[root]['Subject'])
+        ui.write('State: %s\n' % mbox[root]['State'])
+        ui.write('-'*70 + '\n')
+    _write_message(ui, msg, comment, skip = ('skip' in opts) and opts['skip'])
+    ui.write('-'*70 + '\n')
+
+    # Read the mailbox into the messages and children dictionaries
+    messages = {}
+    children = {}
+    i = 0
+    for k in keys:
+        m = mbox[k]
+        messages[m['Message-Id']] = (i,m)
+        children.setdefault(m['In-Reply-To'], []).append(m['Message-Id'])
+        i += 1
+    children[None] = []                # Safeguard against infinte loop on empty Message-Id
+
+    # Iterate over children
+    id = msg['Message-Id']
+    id_stack = (id in children and map(lambda x: (x, 1), reversed(children[id]))) or []
+    if not id_stack: return
+    ui.write('Comments:\n')
+    while id_stack:
+        id,offset = id_stack.pop()
+        id_stack += (id in children and map(lambda x: (x, offset+1), reversed(children[id]))) or []
+        index, msg = messages[id]
+        ui.write('  '*offset + '%d: [%s] %s\n' % (index, util.shortuser(msg['From']), msg['Subject']))
+    ui.write('-'*70 + '\n')
+
+def _find_root_key(maildir):
+    for k,m in maildir.iteritems():
+        if 'in-reply-to' not in m:
+            return k
+
+def _order_keys_date(mbox):
+    keys = mbox.keys()
+    root = _find_root_key(mbox)
+    keys.sort(lambda k1,k2: -(k1 == root) or cmp(util.parsedate(mbox[k1]['date']), util.parsedate(mbox[k2]['date'])))
+    return keys
+
+def _find_mbox_date(mbox, root, order):
+    if order == 'latest':
+        keys = _order_keys_date(mbox)
+        msg = mbox[keys[-1]]
+    else:   # new
+        msg = mbox[root]
+    return util.parsedate(msg['date'])
+
+def _random_id():
+    return "%x" % random.randint(2**63, 2**64-1)
+
+def _create_missing_dirs(issues_path, issue):
+    for d in maildir_dirs:
+        path = os.path.join(issues_path,issue,d)
+        if not os.path.exists(path): os.mkdir(path)
+
+def _create_all_missing_dirs(issues_path, issues):
+    for i in issues:
+        _create_missing_dirs(issues_path, i)
+
+def _humanreadable(size):
+    if size > 1024*1024:
+        return '%5.1fM' % (float(size) / (1024*1024))
+    elif size > 1024:
+        return '%5.1fK' % (float(size) / 1024)
+    else:
+        return '%dB' % size
+
+def _attach_files(msg, filenames):
+    outer = MIMEMultipart()
+    for k in msg.keys(): outer[k] = msg[k]
+    outer.attach(MIMEText(msg.get_payload()))
+
+    for filename in filenames:
+        ctype, encoding = mimetypes.guess_type(filename)
+        if ctype is None or encoding is not None:
+            # No guess could be made, or the file is encoded (compressed), so
+            # use a generic bag-of-bits type.
+            ctype = 'application/octet-stream'
+        maintype, subtype = ctype.split('/', 1)
+        if maintype == 'text':
+            fp = open(filename)
+            # Note: we should handle calculating the charset
+            attachment = MIMEText(fp.read(), _subtype=subtype)
+            fp.close()
+        elif maintype == 'image':
+            fp = open(filename, 'rb')
+            attachment = MIMEImage(fp.read(), _subtype=subtype)
+            fp.close()
+        elif maintype == 'audio':
+            fp = open(filename, 'rb')
+            attachment = MIMEAudio(fp.read(), _subtype=subtype)
+            fp.close()
+        else:
+            fp = open(filename, 'rb')
+            attachment = MIMEBase(maintype, subtype)
+            attachment.set_payload(fp.read())
+            fp.close()
+            # Encode the payload using Base64
+            encoders.encode_base64(attachment)
+        # Set the filename parameter
+        attachment.add_header('Content-Disposition', 'attachment', filename=filename)
+        outer.attach(attachment)
+    return outer
+
+def _read_formats(ui):
+    formats = []
+    global default_format
+
+    for k,v in ui.configitems('artemis'):
+        if not k.startswith('format'): continue
+        if k == 'format':
+            default_format = v
+            continue
+        formats.append((k.split(':')[1], v))
+
+    return formats
+
+def _format_match(props, formats):
+    for k,v in formats:
+        eq = k.split('&')
+        eq = [e.split('*') for e in eq]
+        for e in eq:
+            if props[e[0]] != e[1]:
+                break
+        else:
+            return v
+
+    return default_format
+
+def _summary_line(mbox, root, issue, formats):
+    props = PropertiesDictionary(mbox[root])
+    props['id']  = issue
+    props['len'] = len(mbox)-1              # number of replies (-1 for self)
+
+    return _format_match(props, formats) % props
+
+class PropertiesDictionary(dict):
+    def __init__(self, msg):
+        # Borrowed from termcolor
+        for k,v in zip(['bold', 'dark', '', 'underline', 'blink', '', 'reverse', 'concealed'], range(1, 9)) + \
+                   zip(['grey', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'], range(30, 38)):
+            self[k] = '\033[' + str(v) + 'm'
+        self['reset']  = '\033[0m'
+        del self['']
+
+        for k,v in msg.items():
+            self[k] = v
+
+    def __contains__(self, k):
+        return super(PropertiesDictionary, self).__contains__(k.lower())
+
+    def __getitem__(self, k):
+        if k not in self: return ''
+        return super(PropertiesDictionary, self).__getitem__(k.lower())
+
+    def __setitem__(self, k, v):
+        super(PropertiesDictionary, self).__setitem__(k.lower(), v)
+
+
+cmdtable = {
+    'ilist':    (ilist,
+                 [('a', 'all', False,
+                   'list all issues (by default only those with state new)'),
+                  ('p', 'property', [],
+                   'list issues with specific field values (e.g., -p state=fixed); lists all possible values of a property if no = sign'),
+                  ('o', 'order', 'new', 'order of the issues; choices: "new" (date submitted), "latest" (date of the last message)'),
+                  ('d', 'date', '', 'restrict to issues matching the date (e.g., -d ">12/28/2007)"'),
+                  ('f', 'filter', '', 'restrict to pre-defined filter (in %s/%s*)' % (default_issues_dir, filter_prefix))],
+                 _('hg ilist [OPTIONS]')),
+    'iadd':       (iadd,
+                 [('a', 'attach', [],
+                   'attach file(s) (e.g., -a filename1 -a filename2)'),
+                  ('p', 'property', [],
+                   'update properties (e.g., -p state=fixed)'),
+                  ('n', 'no-property-comment', None,
+                   'do not add a comment about changed properties'),
+                  ('m', 'message', '',
+                   'use <text> as an issue subject'),
+                  ('c', 'commit', False,
+                   'perform a commit after the addition')],
+                 _('hg iadd [OPTIONS] [ID] [COMMENT]')),
+    'ishow':      (ishow,
+                 [('a', 'all', None, 'list all comments'),
+                  ('s', 'skip', '>', 'skip lines starting with a substring'),
+                  ('x', 'extract', [], 'extract attachments (provide attachment number as argument)'),
+                  ('', 'mutt', False, 'use mutt to show issue')],
+                 _('hg ishow [OPTIONS] ID [COMMENT]')),
+}
+
+# vim: expandtab
--- a/aux/convert-mbox-date.py	Thu Jun 10 08:34:23 2010 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,18 +0,0 @@
-#!/usr/bin/env python
-
-from mercurial.util import parsedate, datestr
-import glob, mailbox
-
-issues = glob.glob('.issues/*')
-
-for i in issues:
-	mbox=mailbox.mbox(i)
-	for k in xrange(len(mbox)):
-		msg = mbox[k]
-		print msg['Date']
-		d = parsedate(msg['Date'], ['%a, %d %b %Y %H:%M:%S %Z', '%a, %d %b %Y %H:%M:%S'])
-		print d
-		print datestr(d, '%a, %d %b %Y %H:%M:%S')
-		msg.replace_header('Date', datestr(d, '%a, %d %b %Y %H:%M:%S'))
-		mbox[k] = msg
-	mbox.flush()
--- a/aux/convert-mbox-maildir.py	Thu Jun 10 08:34:23 2010 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,18 +0,0 @@
-import mailbox, glob, os.path, os
-import artemis
-from mercurial import ui, hg
-
-repo = hg.repository(ui.ui())
-
-issue_filenames = glob.glob(os.path.join(artemis.issues_dir, '*'))
-for fn in issue_filenames:
-    mb = mailbox.mbox(fn)
-    messages = [m for m in mb]
-    mb.close()
-    os.unlink(fn)
-    repo.remove([fn])
-    md = mailbox.Maildir(fn)
-    md.lock()
-    keys = [md.add(m) for m in messages]
-    md.close()
-    for k in keys: repo.add([fn + '/new/' + k])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/contrib/convert-mbox-date.py	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,18 @@
+#!/usr/bin/env python
+
+from mercurial.util import parsedate, datestr
+import glob, mailbox
+
+issues = glob.glob('.issues/*')
+
+for i in issues:
+	mbox=mailbox.mbox(i)
+	for k in xrange(len(mbox)):
+		msg = mbox[k]
+		print msg['Date']
+		d = parsedate(msg['Date'], ['%a, %d %b %Y %H:%M:%S %Z', '%a, %d %b %Y %H:%M:%S'])
+		print d
+		print datestr(d, '%a, %d %b %Y %H:%M:%S')
+		msg.replace_header('Date', datestr(d, '%a, %d %b %Y %H:%M:%S'))
+		mbox[k] = msg
+	mbox.flush()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/contrib/convert-mbox-maildir.py	Thu Apr 28 08:32:55 2011 -0700
@@ -0,0 +1,18 @@
+import mailbox, glob, os.path, os
+import artemis
+from mercurial import ui, hg
+
+repo = hg.repository(ui.ui())
+
+issue_filenames = glob.glob(os.path.join(artemis.issues_dir, '*'))
+for fn in issue_filenames:
+    mb = mailbox.mbox(fn)
+    messages = [m for m in mb]
+    mb.close()
+    os.unlink(fn)
+    repo.remove([fn])
+    md = mailbox.Maildir(fn)
+    md.lock()
+    keys = [md.add(m) for m in messages]
+    md.close()
+    for k in keys: repo.add([fn + '/new/' + k])