artemis.py
author Dmitriy Morozov <morozov@cs.duke.edu>
Wed, 16 Apr 2008 17:21:40 -0400
changeset 21 5b3579dc7abf
parent 20 1630cf85c7f7
child 24 17a8293bbbbf
permissions -rw-r--r--
Switched to maildir

# Author: Dmitriy Morozov <hg@foxcub.org>, 2007
        
"""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


state = {'new': 'new', 'fixed': 'fixed'}
state['default'] = state['new']
issues_dir = ".issues"
filter_prefix = ".filter"
date_format = '%a, %d %b %Y %H:%M:%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'])

    # Find issues
    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, '*'))

    # 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.warning('No filter %s defined\n', opts['filter'])
        else:
            properties += config.items(opts['filter'])

    properties += _get_properties(opts['property'])

    for issue in issues:
        mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage)
        root = _find_root_key(mbox)
        property_match = True
        for property,value in properties:
            property_match = property_match and (mbox[root][property] == value)
        if not show_all and (not properties or not property_match) and (properties or mbox[root]['State'].upper() == state['fixed'].upper()): continue


        if match_date and not date_match(util.parsedate(mbox[root]['date'])[0]): continue
        ui.write("%s (%3d) [%s]: %s\n" % (issue[len(issues_path)+1:], # +1 for trailing /
                                          len(mbox)-1,                # number of replies (-1 for self)
                                          mbox[root]['State'],
                                          mbox[root]['Subject']))


def iadd(ui, repo, id = None, comment = 0):
    """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_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

    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."

    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

    # Create the message
    msg = mailbox.MaildirMessage(issue)
    #msg.set_from('artemis', True)

    # 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)
    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:
        msg.add_header('Message-Id', "<%s-0-artemis@%s>" % (issue_id, socket.gethostname()))
    else:
        root = keys[0]
        msg.add_header('Message-Id', "<%s-%s-artemis@%s>" % (issue_id, _random_id(), socket.gethostname()))
        msg.add_header('References', mbox[(comment < len(mbox) and keys[comment]) or root]['Message-Id'])
        msg.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(msg)])   # +1 for the trailing /
    mbox.close()

    # If adding issue, add the new mailbox to the repository
    if not id:
        ui.status('Added new issue %s\n' % issue_id)


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
    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)
            ui.write('-'*70 + '\n')
            i += 1
        return

    _show_mbox(ui, mbox, comment)


def iupdate(ui, repo, id, **opts):
    """Update properties of issue ID"""

    issue, id = _find_issue(ui, repo, id)
    if not issue: return

    properties = _get_properties(opts['property'])

    # Read the issue
    mbox = mailbox.Maildir(issue, factory=mailbox.MaildirMessage)
    root = _find_root_key(mbox)
    msg = mbox[root]

    # Fix the properties
    properties_text = ''
    for property, value in properties:
        if property in msg:
            msg.replace_header(property, value)
        else:
            msg.add_header(property, value)
        properties_text += '%s=%s\n' % (property, value)
    mbox.lock()
    mbox[root] = msg

    # Write down a comment about updated properties
    if properties and not opts['no_property_comment']:
        user = ui.username()
        properties_text  =     "From: %s\nDate: %s\nSubject: properties changes (%s)\n\n%s" % \
                            (user, util.datestr(format = date_format),
                             _pretty_list(list(set([property for property, value in properties]))),
                             properties_text)
        msg = mailbox.mboxMessage(properties_text)
        msg.add_header('Message-Id', "<%s-%s-artemis@%s>" % (id, _random_id(), socket.gethostname()))
        msg.add_header('References', mbox[0]['Message-Id'])
        msg.add_header('In-Reply-To', mbox[0]['Message-Id'])
        #msg.set_from('artemis', True)
        repo.add([issue_fn[(len(repo.root)+1):] + '/new/'  + mbox.add(msg)])   # +1 for the trailing /
    mbox.close()

    # Show updated message
    _show_mbox(ui, mbox, 0)


def _find_issue(ui, repo, id):
    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):
    if index: ui.write("Comment: %d\n" % index)
    if ui.verbose:
        ui.write(message.as_string().strip() + '\n')
    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'])
        ui.write('\n' + message.get_payload().strip() + '\n')

def _show_mbox(ui, mbox, comment):
    # 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)
    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: ' % index) + msg['Subject'] + '\n')
    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 _pretty_list(lst):
    s = ''
    for i in lst:
        s += i + ', '
    return s[:-2]

def _random_id():
    return "%x" % random.randint(2**63, 2**64-1)


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)'),
                  ('d', 'date', '', 'restrict to issues matching the date (e.g., -d ">12/28/2007)"'),
                  ('f', 'filter', '', 'restrict to pre-defined filter (in %s/%s*)' % (issues_dir, filter_prefix))],
                 _('hg ilist [OPTIONS]')),
    'iadd':       (iadd, 
                 [],
                 _('hg iadd [ID] [COMMENT]')),
    'ishow':      (ishow,
                 [('a', 'all', None, 'list all comments')],
                 _('hg ishow [OPTIONS] ID [COMMENT]')),
    'iupdate':    (iupdate,
                 [('p', 'property', [],
                   'update properties (e.g., -p state=fixed)'),
                  ('n', 'no-property-comment', None,
                   'do not add a comment about changed properties')],
                 _('hg iupdate [OPTIONS] ID'))
}

# vim: expandtab