# HG changeset patch # User frostbane <frostbane@programmer.net> # Date 1457046926 -32400 # Node ID 3f7593f1b0aeafb555ad7d6bd9d102bef0f2e224 # Parent 875b47ec73b4853123e96f15b56726fdca7f4717 refactor into separate classes add --index and --output option to ishow add --index option to iadd diff -r 875b47ec73b4 -r 3f7593f1b0ae .gitignore --- a/.gitignore Tue Mar 01 09:05:26 2016 +0900 +++ b/.gitignore Fri Mar 04 08:15:26 2016 +0900 @@ -2,4 +2,7 @@ artemis.egg-info/ build/ dist/ - +.idea/* +*.pyo +*.pyc +pycharm-debug.egg diff -r 875b47ec73b4 -r 3f7593f1b0ae .hgignore --- a/.hgignore Tue Mar 01 09:05:26 2016 +0900 +++ b/.hgignore Fri Mar 04 08:15:26 2016 +0900 @@ -3,3 +3,9 @@ artemis.egg-info/ build/ dist/ +.idea/* +*.pyo +*.pyc +pycharm-debug.egg +syntax: glob +artemis.iml diff -r 875b47ec73b4 -r 3f7593f1b0ae artemis/__init__.py --- a/artemis/__init__.py Tue Mar 01 09:05:26 2016 +0900 +++ b/artemis/__init__.py Fri Mar 04 08:15:26 2016 +0900 @@ -1,3 +1,3 @@ -from artemis import * +from main import * -__version__ = '0.5.0' +__version__ = '0.5.1' diff -r 875b47ec73b4 -r 3f7593f1b0ae artemis/add.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/artemis/add.py Fri Mar 04 08:15:26 2016 +0900 @@ -0,0 +1,224 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + + +from mercurial import hg, util, commands +from mercurial.i18n import _ +import sys, 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 +from artemis import Artemis + +__author__ = 'frostbane' +__date__ = '2016/03/04' + + +class ArtemisAdd: + commands = [ + ('a', 'attach', [], 'Attach file(s) ' + '(e.g., -a filename1 -a filename2,' + '-a ~/Desktop/file.zip)'), + ('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'), + ('i', 'index', '0', 'Index of the message to comment.'), + ('c', 'commit', False, 'Perform a commit after the addition') + ] + usage = 'hg iadd [OPTIONS] [ID] [COMMENT]' + + def __init__(self): + pass + + def attach_files(self, 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=os.path.basename(filename)) + outer.attach(attachment) + return outer + + def random_id(self): + return "%x" % random.randint(2 ** 63, 2 ** 64 - 1) + + def add(self, ui, repo, id=None, **opts): + """Adds a new issue, or comment to an existing issue ID or its + comment COMMENT""" + + comment = int(opts["index"]) + + # First, make sure issues have a directory + issues_dir = ui.config('artemis', 'issues', + default=Artemis.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 = Artemis.find_issue(ui, repo, id) + if not issue_fn: + ui.warn('No such issue\n') + return + Artemis.create_missing_dirs(issues_path, issue_id) + mbox = mailbox.Maildir(issue_fn, + factory=mailbox.MaildirMessage) + keys = Artemis.order_keys_date(mbox) + root = keys[0] + + user = ui.username() + + default_issue_text = "From: %s\nDate: %s\n" % ( + user, util.datestr(format=Artemis.date_format)) + if not id: + default_issue_text += "State: %s\n" % Artemis.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 = Artemis.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' % Artemis.default_state + else: + state_str = '' + issue = "From: %s\nDate: %s\nSubject: %s\n%s" % \ + (user, util.datestr(format=Artemis.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=Artemis.date_format), + properties_subject) + + # Create the message + msg = mailbox.MaildirMessage(issue) + if opts['attach']: + outer = self.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 = self.random_id() + issue_fn = os.path.join(issues_path, issue_id) + mbox = mailbox.Maildir(issue_fn, + factory=mailbox.MaildirMessage) + keys = Artemis.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, self.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 + '/new/' + mbox.add(outer) + commands.add(ui, repo, new_bug_path) + + # Fix properties in the root message + if properties: + root = Artemis.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: + Artemis.show_mbox(ui, mbox, 0) diff -r 875b47ec73b4 -r 3f7593f1b0ae artemis/artemis.py --- a/artemis/artemis.py Tue Mar 01 09:05:26 2016 +0900 +++ b/artemis/artemis.py Fri Mar 04 08:15:26 2016 +0900 @@ -1,10 +1,14 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + # Author: Dmitriy Morozov <hg@foxcub.org>, 2007 -- 2009 +__author__ = "Dmitriy Morozov" """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 sys, os, time, random, mailbox, glob, socket, ConfigParser import mimetypes from email import encoders from email.generator import Generator @@ -14,500 +18,299 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from itertools import izip +from properties import ArtemisProperties -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) +def singleton(cls): + instances = {} - # 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) + def getinstance(): + if cls not in instances: + instances[cls] = cls() - 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 + return instances[cls] - 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) + return getinstance -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) +# @singleton +class Artemis: + """Artemis static and common functions + """ + 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' - 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() + # def __init__(self): + # pass - 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'] + @staticmethod + def create_all_missing_dirs(issues_path, issues): + for issue in issues: + Artemis.create_missing_dirs(issues_path, issue) - # 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) + @staticmethod + def find_issue(ui, repo, id): + issues_dir = ui.config('artemis', 'issues', + default=Artemis.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 - 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]) + elif len(issues) > 1: + ui.status("Multiple choices:\n") + for issue in issues: + ui.status(' ', Artemis.get_issue_id(ui, repo, issue), '\n') - issue = "From: %s\nDate: %s\nSubject: changed properties (%s)\n" % \ - (user, util.datestr(format = date_format), properties_subject) + return False, 0 - # Create the message - msg = mailbox.MaildirMessage(issue) - if opts['attach']: - outer = _attach_files(msg, opts['attach']) - else: - outer = msg + else: + return issues[0], Artemis.get_issue_id(ui, repo, issues[0]) - # 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 + @staticmethod + def get_issues_dir(ui): + issues_dir = ui.config('artemis', + 'issues', + default=Artemis.default_issues_dir) + + return issues_dir - # 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') + @staticmethod + def get_issues_path(ui, repo): + """gets the full path of the issues directory. returns nothing + if the path does not exist. - 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 + '/new/' + mbox.add(outer) - commands.add(ui, repo, new_bug_path) + :Example: + issues_path = Artemis.get_issues_path(ui, repo) + if not issues_path: + # error + else + # path exists - # 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 + """ + issues_dir = Artemis.get_issues_dir(ui) + issues_path = os.path.join(repo.root, issues_dir) + + if not os.path.exists(issues_path): + return + + return issues_path - mbox.close() - - if opts['commit']: - commands.commit(ui, repo, issue_fn) + @staticmethod + def get_all_issues(ui, repo): + # Find issues + issues_path = Artemis.get_issues_path(ui, repo) + if not issues_path: + return [] - # 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""" + issues = glob.glob(os.path.join(issues_path, '*')) - comment = int(comment) - issue, id = _find_issue(ui, repo, id) - if not issue: - return ui.warn('No such issue\n') + Artemis.create_all_missing_dirs(issues_path, issues) - 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) + return issues - 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 + @staticmethod + def get_all_mboxes(ui, repo): + """gets a list of all available mboxes with an added extra + property "issue" - _show_mbox(ui, mbox, comment, skip = opts['skip']) + :param ui: mercurial ui object + :param repo: mercurial repo object + :return: list of all available mboxes + """ + issues = Artemis.get_all_issues(ui, repo) + if not issues: + return [] - 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) - else: - filename = os.path.basename(filename) - fp = open(filename, 'wb') - fp.write(part.get_payload(decode = True)) - fp.close() - counter += 1 + mboxes = [] + for issue in issues: + mbox = mailbox.Maildir(issue, + factory=mailbox.MaildirMessage) + + root = Artemis.find_root_key(mbox) + if not root: # root is None + continue + + # add extra property + mbox.issue = issue + mboxes.append(mbox) + + return mboxes -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 + '*')) + @staticmethod + def get_properties(property_list): + return [p.split('=') for p in property_list] - 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 + @staticmethod + def write_message(ui, message, index=0, skip=None): + if index: + ui.write("Comment: %d\n" % index) - return issues[0], issues[0][len(issues_path)+1:] - -def _get_properties(property_list): - return [p.split('=') for p in property_list] + if ui.verbose: + Artemis.show_text(ui, message.as_string().strip(), skip) + return -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']) + 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 maintype == 'multipart': + continue + if ctype == 'text/plain': ui.write('\n') - _show_text(ui, part.get_payload().strip(), skip) + Artemis.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') + ui.write('\n' + '%d: Attachment [%s, %s]: %s' % ( + counter, ctype, + Artemis.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') + @staticmethod + 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') + @staticmethod + 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') - # 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 + keys = Artemis.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') - # 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 + Artemis.write_message(ui, msg, comment, + skip=('skip' in opts) and opts['skip']) + ui.write('-' * 70 + '\n') -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) + # 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 -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) + # Safeguard against infinte loop on empty Message-Id + children[ + None] = [] -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 + # 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 -def _attach_files(msg, filenames): - outer = MIMEMultipart() - for k in msg.keys(): outer[k] = msg[k] - outer.attach(MIMEText(msg.get_payload())) + 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'])) - 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=os.path.basename(filename)) - outer.attach(attachment) - return outer + ui.write('-' * 70 + '\n') + + @staticmethod + def find_root_key(maildir): + for k, m in maildir.iteritems(): + if 'in-reply-to' not in m: + return k -def _read_formats(ui): - formats = [] - global default_format + @staticmethod + def order_keys_date(mbox): + keys = mbox.keys() + root = Artemis.find_root_key(mbox) + keys.sort(lambda k1, k2: -(k1 == root) or + cmp(util.parsedate(mbox[k1]['date']), + util.parsedate( + mbox[k2]['date']))) - 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 + return keys -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 + @staticmethod + def create_missing_dirs(issues_path, issue): + for dir in Artemis.maildir_dirs: + path = os.path.join(issues_path, issue, dir) + if not os.path.exists(path): + os.mkdir(path) - return default_format + @staticmethod + def humanreadable(size): + if size > 1024 * 1024: + return '%5.1fM' % (float(size) / (1024 * 1024)) -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) + elif size > 1024: + return '%5.1fK' % (float(size) / 1024) - return _format_match(props, formats) % props + else: + return '%dB' % size -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[''] + @staticmethod + def get_issue_id(ui, repo, issue): + """get the issue id provided the issue""" + # Find issues + issues_dir = ui.config('artemis', 'issues', + default=Artemis.default_issues_dir) + issues_path = os.path.join(repo.root, issues_dir) + if not os.path.exists(issues_path): + return "" - 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) + # +1 for trailing / + return issue[len(issues_path) + 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); 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 diff -r 875b47ec73b4 -r 3f7593f1b0ae artemis/list.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/artemis/list.py Fri Mar 04 08:15:26 2016 +0900 @@ -0,0 +1,219 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from mercurial import hg, util, commands +import sys, os, time, random, mailbox, glob, socket, ConfigParser +from properties import ArtemisProperties +from artemis import Artemis + +__author__ = 'frostbane' +__date__ = '2016/03/04' + + +class ArtemisList: + commands = [ + ('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'), + ("", "all-properties", None, "list all available properties"), + ('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*)' + '' % (Artemis.default_issues_dir, + Artemis.filter_prefix)) + ] + + usage = 'hg ilist [OPTIONS]' + + def __init__(self): + pass + + def __get_properties(self, ui, repo): + """get the list of all available properties + :param ui: mercurial ui object + :param repo: mercurial repo object + :return: returns a list of all available properties + """ + + properties = [] + + mboxes = Artemis.get_all_mboxes(ui, repo) + for mbox in mboxes: + root = Artemis.find_root_key(mbox) + properties = list(set(properties + mbox[root].keys())) + + return properties + + def __read_formats(self, ui): + formats = [] + + for key, value in ui.configitems('artemis'): + if not key.startswith('format'): + continue + if key == 'format': + Artemis.default_format = value + continue + formats.append((key.split(':')[1], value)) + + return formats + + def __format_match(self, props, formats): + for key, value in formats: + eq = key.split('&') + eq = [e.split('*') for e in eq] + + for e in eq: + # todo check if else + if props[e[0]] != e[1]: + break + else: + return value + + return Artemis.default_format + + def __summary_line(self, mbox, root, issue, formats): + props = ArtemisProperties(mbox[root]) + props['id'] = issue + # number of replies (-1 for self) + props['len'] = len(mbox) - 1 + + return self.__format_match(props, formats) % props + + def __find_mbox_date(self, mbox, root, order): + if order == 'latest': + keys = Artemis.order_keys_date(mbox) + msg = mbox[keys[-1]] + + else: # new + msg = mbox[root] + + return util.parsedate(msg['date']) + + def __find_issues(self, ui, repo): + issues_path = Artemis.get_issues_path(ui, repo) + if not issues_path: + return + + issues = Artemis.get_all_issues(ui, repo) + + return issues + + def __proc_filters(self, properties, ui, repo, opts): + if opts['filter']: + filters = glob.glob( + os.path.join(Artemis.get_issues_path(ui, repo), + Artemis.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']) + + return properties + + def __list_summaries(self, issues, properties, ui, repo, opts): + # Process options + show_all = opts['all'] + 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 = self.__read_formats(ui) + + cmd_properties = Artemis.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 = Artemis.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]) + + has_property = properties or \ + mbox[root]['State'].upper() in \ + [f.upper() for f in Artemis.state['resolved']] + + if not show_all and (not properties or + not property_match) and has_property: + continue + + if match_date and not date_match( + util.parsedate(mbox[root]['date'])[0]): + continue + + if not list_properties: + mbox_date = self.__find_mbox_date(mbox, root, order) + sum_line = self.__summary_line( + mbox, + root, + Artemis.get_issue_id(ui, repo, issue), + formats) + + summaries.append((sum_line, mbox_date)) + + 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 list(self, ui, repo, **opts): + """List issues associated with the project""" + + # Find issues + issues = self.__find_issues(ui, repo) + if not issues: + return + + properties = [] + if opts["all_properties"]: + properties = self.__get_properties(ui, repo) + for property in properties: + ui.write("%s\n" % property) + + return + + # Process filter + properties = self.__proc_filters(properties, ui, repo, opts) + + self.__list_summaries(issues, properties, ui, repo, opts) + + diff -r 875b47ec73b4 -r 3f7593f1b0ae artemis/main.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/artemis/main.py Fri Mar 04 08:15:26 2016 +0900 @@ -0,0 +1,38 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from mercurial import hg, util, commands +from mercurial.i18n import _ +import sys, 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 +from artemis import Artemis +from list import ArtemisList +from add import ArtemisAdd +from show import ArtemisShow + +__author__ = 'frostbane' +__date__ = '2016/03/02' + + +cmdtable = { + 'ilist' : (ArtemisList().list, + ArtemisList.commands, + _(ArtemisList.usage)), + 'iadd' : (ArtemisAdd().add, + ArtemisAdd.commands, + _(ArtemisAdd.usage)), + 'ishow' : (ArtemisShow().show, + ArtemisShow.commands, + _(ArtemisShow.usage)), +} + +if __name__ == "__main__": + pass diff -r 875b47ec73b4 -r 3f7593f1b0ae artemis/properties.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/artemis/properties.py Fri Mar 04 08:15:26 2016 +0900 @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +__author__ = 'frostbane' +__date__ = '2016/03/03' + +class ArtemisProperties(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(ArtemisProperties, self).__contains__( + k.lower()) + + def __getitem__(self, k): + if k not in self: + return '' + return super(ArtemisProperties, self).__getitem__( + k.lower()) + + def __setitem__(self, k, v): + super(ArtemisProperties, self).__setitem__(k.lower(), v) diff -r 875b47ec73b4 -r 3f7593f1b0ae artemis/show.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/artemis/show.py Fri Mar 04 08:15:26 2016 +0900 @@ -0,0 +1,127 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +from mercurial import hg, util, commands +from mercurial.i18n import _ +import sys, 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 +from artemis import Artemis + +__author__ = 'frostbane' +__date__ = '2016/03/04' + + +class ArtemisShow: + commands = [ + ('a', 'all', None, 'List all comments.'), + ('s', 'skip', '>', 'Skip lines starting with a substring.'), + ('x', 'extract', [], 'Extract attachment(s) (provide ' + 'attachment number as argument). If the ' + '"message" option is not specified the ' + 'attachment of the first message (the ' + 'issue itself) will be extracted. File ' + 'will be overwritten if it is already ' + 'existing. ' + '(e.g. -x 1 -x 2 -m 1 -o tmp)'), + ('i', 'index', 0, 'Message number to be shown (0 based ' + 'index, 0 is the issue and 1 onwards will ' + 'be the rest of the replies). If "extract" ' + 'option is set then the attachment of the ' + 'message will be extracted. This option is ' + 'ignored if the "all" option is specified.'), + ('o', 'output', '"./tmp"', 'Extract output directory ' + '(e.g. -o "./files/attachments")'), + ('', 'mutt', False, 'Use mutt to show the issue.') + ] + usage = 'hg ishow [OPTIONS] ID' + + def __init__(self): + pass + + def show(self, ui, repo, id, **opts): + """Shows issue ID, or possibly its comment COMMENT""" + + issue, id = Artemis.find_issue(ui, repo, id) + if not issue: + return ui.warn('No such issue\n') + + issues_dir = ui.config('artemis', 'issues', + default=Artemis.default_issues_dir) + Artemis.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) + + # show the first mail + ui.write('=' * 70 + '\n') + keys = Artemis.order_keys_date(mbox) + Artemis.write_message(ui, mbox[keys[0]], 0, skip=opts['skip']) + + if opts['all']: + ui.write('=' * 70 + '\n') + i = 0 + for k in keys: + if (i > 0): + Artemis.write_message(ui, mbox[k], i, + skip=opts['skip']) + ui.write('-' * 70 + '\n') + i += 1 + return + + elif int(opts["index"]) > 0 and len(keys) > int( + opts["index"]): + # todo comments replied to this comment should also be shown + reply_num = int(opts["index"]) + ui.write('-' * 70 + '\n') + Artemis.write_message( + ui, mbox[keys[reply_num]], reply_num, + skip=opts['skip']) + ui.write('=' * 70 + '\n') + + if opts['extract']: + attachment_numbers = map(int, opts['extract']) + msg = mbox[keys[opts["index"]]] + + 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) + + else: + filename = os.path.basename(filename) + + dirname = opts["output"] + if not os.path.exists(dirname): + os.makedirs(dirname) + + pathFileName = os.path.join(dirname, filename) + + fp = open(pathFileName, 'wb') + fp.write(part.get_payload(decode=True)) + fp.close() + + counter += 1