# HG changeset patch # User Dmitriy Morozov <dmitriy@mrzv.org> # Date 1491685134 25200 # Node ID 6368495f10a502523b47b53873d7528beb051d88 # Parent 979145ac3ccce2e1df7c37850e61ddc1726b4d48 Back out frostbane's changes, going back to af9913ddbb3d diff -r 979145ac3ccc -r 6368495f10a5 .gitignore --- a/.gitignore Wed Mar 15 13:12:28 2017 -0400 +++ b/.gitignore Sat Apr 08 13:58:54 2017 -0700 @@ -1,7 +1,5 @@ -*.pyo *.pyc artemis.egg-info/ build/ dist/ -.idea/* -pycharm-debug.egg + diff -r 979145ac3ccc -r 6368495f10a5 .hgignore --- a/.hgignore Wed Mar 15 13:12:28 2017 -0400 +++ b/.hgignore Sat Apr 08 13:58:54 2017 -0700 @@ -1,10 +1,5 @@ syntax:glob -*.pyo *.pyc artemis.egg-info/ build/ dist/ -.idea/* -pycharm-debug.egg -syntax: glob -artemis.iml diff -r 979145ac3ccc -r 6368495f10a5 README.md --- a/README.md Wed Mar 15 13:12:28 2017 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,365 +0,0 @@ -# Artemis # ------------ - -> [Artemis](http://www.mrzv.org/software/artemis/) is a lightweight distributed issue tracking extension for [Mercurial](http://www.selenic.com/mercurial/). - -* [Setup](#markdown-header-setup) -* [Example](#markdown-header-example) -* [Commands](#markdown-header-commands) -* [Filters](#markdown-header-filters) -* [Format](#markdown-header-format) - - -Individual issues are stored in directories in an `.issues` subdirectory (overridable in a config file). Each one is a `Maildir` and each one is assumed to have a single root message. Various properties of an issue are stored in the headers of that message. - -One can obtain Artemis by cloning its [repository](http://hg.mrzv.org/Artemis/): - -``` - $ hg clone http://hg.mrzv.org/Artemis/ -``` - -or downloading the entire repository as a [tarball](http://hg.mrzv.org/Artemis/archive/tip.tar.gz). - - - -## Setup ## ------------ - -In the `[extensions]` section of your `~/.hgrc` add: - -``` - artemis = /path/to/Artemis/artemis -``` - -Optionally, provide a section `[artemis]` in your `~/.hgrc` file or the repository local `.hg/hgrc` file, and specify an alternative path for the issues subdirectory (instead of the default `.issues`): - -``` - [artemis] - issues = _issues -``` - -Additionally, one can specify [filters](#markdown-header-filters) and output [formats](#markdown-header-format). - -### Windows ### ---------------- - -The TortoiseHg for windows comes with a sandboxed Python, that means that thg will not be using the Python installed in the system. - -If you get a `No module named mailbox!` error find the `mailbox.py` under `%PYTHON_PATH%\Lib` and add it to the `%HG_PATH%\lib\library.zip`. - - -[↑ back to top](#markdown-header-artemis) - - -## Example ## -------------- - -**Create an issue:** - -``` - $ hg iadd - ... enter some text in an editor ... -``` -. -``` - Added new issue 907ab57e04502afd -``` - -**List the issues:** - -``` - $ hg ilist - 907ab57e04502afd ( 0) [new]: New issue -``` - -**Show an issue:** - -``` - $ hg ishow 907ab57e04502afd - ====================================================================== - From: ... - Date: ... - Subject: New issue - State: new - - Detailed description. - - ---------------------------------------------------------------------- -``` - -**Add a comment to the issue:** - -``` - $ hg iadd 907ab57e04502afd - ... enter the comment text -``` -. -``` - ====================================================================== - From: ... - [snip] - Detailed description. - - ---------------------------------------------------------------------- - Comments: - 1: [dmitriy] Some comment - ---------------------------------------------------------------------- -``` - -**And a comment to the comment:** - -``` - $ hg iadd 907ab57e04502afd -i 1 - ... enter the comment text ... -``` -. -``` - ====================================================================== - From: ... - [snip] - Detailed description. - - ---------------------------------------------------------------------- - Comments: - 1: [dmitriy] Some comment - 2: [dmitriy] Comment on a comment - ---------------------------------------------------------------------- -``` - -**Close the issue:** - -``` - $ hg iadd 907ab57e04502afd -p state=resolved -p resolution=fixed -n -``` -. -``` - ====================================================================== - From: ... - [snip] - Detailed description. - - ---------------------------------------------------------------------- - Comments: - 1: [dmitriy] Some comment - 2: [dmitriy] Comment on a comment - 3: [dmitriy] changed properties (state=resolved, resolution=fixed) - ---------------------------------------------------------------------- -``` - -**No more new issues, and one resolved issue:** - -``` - $ hg ilist - $ hg ilist -a - 907ab57e04502afd ( 3) [resolved=fixed]: New issue -``` - -The fact that issues are Maildirs, allows one to look at them in, for example, `mutt` with predictable results: - -``` - mutt -Rf .issues/907ab57e04502afd -``` - -**Search the issues:** - -Search a property using regular expressions -``` - hg ifind -p state -r "fix(ed)?|resolve(d)?" - 907ab57e04502afd ( 3) [resolved]: New issue - baca6256d98fb593 ( 1) [resolved]: Another issue -``` - -Search the message for a string -``` - hg ifind -mn "comment" - 907ab57e04502afd ( 3) [resolved]: New issue - baca6256d98fb593 ( 1) [resolved]: Another issue - ba564b23fcff6358 ( 1) [new]: New issue -``` - - - -[↑ back to top](#markdown-header-artemis) - -## Commands ## --------------- - -**hg iadd [OPTIONS] [ID]** - -> Adds a new issue, or comment to an existing issue ID or its comment COMMENT - - -options: - -| | | description | -|-----|-----------------------|---------------------------------------| -| -a | --attach VALUE [+] | attach file(s) | -| | | (e.g., -a filename1 -a filename2) | -| -p | --property VALUE [+] | update properties | -| | | (e.g. -p state=fixed, | -| | | -p state=resolved | -| | | -p resolution=fixed) | -| -n | --no-property-comment | do not add a comment about changed | -| | | properties | -| -m | --message VALUE | use <text> as an issue subject | -| -i | --index VALUE | 0 based index of the message to show | -| | | (default: 0) | -| -c | --commit | perform a commit after the addition | - -[+] marked option can be specified multiple times - - - - - -**hg ilist [OPTIONS]** - -> List issues associated with the project - -options: - -| | | description | -|-----|----------------------|----------------------------------------| -| -a | --all | list all issues | -| | | (by default only those with state new) | -| -p | --property VALUE [+] | list issues with specific field values | -| | | (e.g., -p state=fixed, | -| | | -p state=resolved -p category=doc); | -| | | lists all possible values of a | -| | | property if no = sign is provided. | -| | | (e.g. -p category) | -| | --all-properties | list all available properties | -| -o | --order VALUE | order of the issues; choices: "new" | -| | | (date submitted), "latest" | -| | | (date of the last message) | -| | | (default: new) | -| -d | --date VALUE | restrict to issues matching the date | -| | | (e.g., -d ">12/28/2007)" | -| -f | --filter VALUE | restrict to pre-defined filter | -| | | (in .issues/.filter*) | - -[+] marked option can be specified multiple times - - - -**hg ishow [OPTIONS] ID** - -> Shows issue ID, or possibly its comment COMMENT - -options: - -| | | description | -|-----|---------------------|-----------------------------------------| -| -a | --all | list all comments | -| -s | --skip VALUE | skip lines starting with a substring | -| | | (default: >) | -| -x | --extract VALUE [+] | extract attachments | -| | | (provide attachment number as argument) | -| -i | --index VALUE | 0 based index of the message to show | -| | | (default: 0) | -| -o | --output VALUE | extract output directory | -| | --mutt | use mutt to show issue | - -[+] marked option can be specified multiple times - - - -**hg ifind [OPTIONS] QUERY** - -> Shows a list of issues matching the specified QUERY - -options: - -| | | description | -|-----|-------------------|-------------------------------------------| -| -p | --property VALUE | issue property to match | -| | | [state, from, subject, date, priority, | -| | | [resolution, ticket, etc..] | -| | | (default: subject) | -| -n | --no-property | Do not match the property. Use with | -| | | --message to search only the message. The | -| | | --property will be ignored. | -| -m | --message | Search the message. If no match is found | -| | | it will then search the property for a | -| | | match. Use a blank property to ignore the | -| | | property search. | -| -c | --case-sensitive | case sensitive search | -| -r | --regex | use regular expressions | -| | | (exact option will be ignored) | -| -e | --exact | use exact comparison | -| | | like comparison is used if uspecified | - -[+] marked option can be specified multiple times - - - - - - - -[↑ back to top](#markdown-header-artemis) - - -## Filters ## -------------- - -Artemis scans all files of the form `.issues/.filter*`, and processes them as config files. Section names become filter names, and the individual settings become properties. For example the following: - -``` - [olddoc] - category=documentation - state=resolved -``` - -placed in a file `.issues/.filterMyCustom` creates a filter `olddoc` which can be invoked with the `ilist` command: - -``` - hg ilist -f olddoc -``` - - -[↑ back to top](#markdown-header-artemis) - - - -## Format ## ------------- - -One can specify the output format for the `ilist` command in the global hg configuration `~/.hgrc` or the repository local configuration `.hg/hgrc`. - -``` - hg config --edit -``` - -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](http://en.wikipedia.org/wiki/ANSI_escape_code), so one could color the summary lines: - -``` - format:state*new = %(red)s%(bold)s%(id)s (%(len)3d) [%(state)s]: %(Subject)s%(reset)s -``` - - -[↑ back to top](#markdown-header-artemis) - - - --------------------------------------------------------------------- - -[http://www.mrzv.org/software/artemis/](http://www.mrzv.org/software/artemis/) diff -r 979145ac3ccc -r 6368495f10a5 artemis/__init__.py --- a/artemis/__init__.py Wed Mar 15 13:12:28 2017 -0400 +++ b/artemis/__init__.py Sat Apr 08 13:58:54 2017 -0700 @@ -1,3 +1,3 @@ -from main import * +from artemis import * -__version__ = '0.5.1' +__version__ = '0.5.0' diff -r 979145ac3ccc -r 6368495f10a5 artemis/add.py --- a/artemis/add.py Wed Mar 15 13:12:28 2017 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,224 +0,0 @@ -#!/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 979145ac3ccc -r 6368495f10a5 artemis/artemis.py --- a/artemis/artemis.py Wed Mar 15 13:12:28 2017 -0400 +++ b/artemis/artemis.py Sat Apr 08 13:58:54 2017 -0700 @@ -1,14 +1,10 @@ -#!/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 sys, os, time, random, mailbox, glob, socket, ConfigParser +import os, time, random, mailbox, glob, socket, ConfigParser import mimetypes from email import encoders from email.generator import Generator @@ -18,299 +14,500 @@ from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from itertools import izip -from properties import ArtemisProperties -def singleton(cls): - instances = {} +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 getinstance(): - if cls not in instances: - instances[cls] = cls() + # 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) - return instances[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 getinstance + 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) -# @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' +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) - # def __init__(self): - # pass + 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() - @staticmethod - def create_all_missing_dirs(issues_path, issues): - for issue in issues: - Artemis.create_missing_dirs(issues_path, issue) + 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 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 + # 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) - elif len(issues) > 1: - ui.status("Multiple choices:\n") - for issue in issues: - ui.status(' ', Artemis.get_issue_id(ui, repo, issue), '\n') + 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]) - return False, 0 + issue = "From: %s\nDate: %s\nSubject: changed properties (%s)\n" % \ + (user, util.datestr(format = date_format), properties_subject) - else: - return issues[0], Artemis.get_issue_id(ui, repo, issues[0]) + # Create the message + msg = mailbox.MaildirMessage(issue) + if opts['attach']: + outer = _attach_files(msg, opts['attach']) + else: + outer = msg - @staticmethod - def get_issues_dir(ui): - issues_dir = ui.config('artemis', - 'issues', - default=Artemis.default_issues_dir) - - return issues_dir + # 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_path(ui, repo): - """gets the full path of the issues directory. returns nothing - if the path does not exist. + # 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') - :Example: - issues_path = Artemis.get_issues_path(ui, repo) - if not issues_path: - # error - else - # path exists + 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) - """ - 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 + # 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 - @staticmethod - def get_all_issues(ui, repo): - # Find issues - issues_path = Artemis.get_issues_path(ui, repo) - if not issues_path: - return [] + mbox.close() + + if opts['commit']: + commands.commit(ui, repo, issue_fn) - issues = glob.glob(os.path.join(issues_path, '*')) + # 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""" - Artemis.create_all_missing_dirs(issues_path, issues) + comment = int(comment) + issue, id = _find_issue(ui, repo, id) + if not issue: + return ui.warn('No such issue\n') - return 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) - @staticmethod - def get_all_mboxes(ui, repo): - """gets a list of all available mboxes with an added extra - property "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 - :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 [] + _show_mbox(ui, mbox, comment, skip = opts['skip']) - 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 + 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 - @staticmethod - def get_properties(property_list): - return [p.split('=') for p in property_list] +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 write_message(ui, message, index=0, skip=None): - if index: - ui.write("Comment: %d\n" % index) + 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 - if ui.verbose: - Artemis.show_text(ui, message.as_string().strip(), skip) - return + return issues[0], issues[0][len(issues_path)+1:] + +def _get_properties(property_list): + return [p.split('=') for p in property_list] - 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']) - +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 maintype == 'multipart': continue if ctype == 'text/plain': ui.write('\n') - Artemis.show_text(ui, part.get_payload().strip(), - skip) - + _show_text(ui, part.get_payload().strip(), skip) else: filename = part.get_filename() - ui.write('\n' + '%d: Attachment [%s, %s]: %s' % ( - counter, ctype, - Artemis.humanreadable(len(part.get_payload())), - filename) + '\n') + ui.write('\n' + '%d: Attachment [%s, %s]: %s' % (counter, ctype, _humanreadable(len(part.get_payload())), filename) + '\n') counter += 1 - @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_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_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') +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') - 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') + # 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 - Artemis.write_message(ui, msg, comment, - skip=('skip' in opts) and opts['skip']) - 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 - # 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 _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) - # Safeguard against infinte loop on empty Message-Id - children[ - None] = [] +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) - # 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 _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 - 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'])) +def _attach_files(msg, filenames): + outer = MIMEMultipart() + for k in msg.keys(): outer[k] = msg[k] + outer.attach(MIMEText(msg.get_payload())) - 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 + 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 - @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']))) +def _read_formats(ui): + formats = [] + global default_format - return keys + 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 - @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) +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 humanreadable(size): - if size > 1024 * 1024: - return '%5.1fM' % (float(size) / (1024 * 1024)) + return default_format - elif size > 1024: - return '%5.1fK' % (float(size) / 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) - else: - return '%dB' % size + return _format_match(props, formats) % props - @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 "" +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[''] - # +1 for trailing / - return issue[len(issues_path) + 1:] + 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 diff -r 979145ac3ccc -r 6368495f10a5 artemis/find.py --- a/artemis/find.py Wed Mar 15 13:12:28 2017 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,218 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -""" - .. ┌ -: . - ([]▄▄├]▄▄▄¿┐`, - .¡ ,. D{}▓███████████░█∩^} = - ,▓¼ .▐▓▓█████████████▓██▓▓, (▓ - █▌╕. ─@╣████████████████████▄─ ./▀' - :▓█▌Ç... └ '└▓██████████████████████Ö'` .. '└`'┌ - \ g█████▄Ü┌ C g██████████████████████████╗─ ² ² t▄▓██ù- `╞ - ¡ Å╠█▌╠█▓▓▓▓▌N@▌| ╓██████████████████████████▌ .╣▓▓▓▓▓▓▓]▐╙ÅÅ∩! - ` ` ╫½┌▀▀▀▀▀▀░╫▌┘ ▀▀▀▀▀██████████████████▀█▀C :╝▀▀▀▀▀▀▀. `: - L ─ⁿ'``' ─ ''' ─█████████████████▌─█= ╞º'`'º── - ..Å╫█╛. ~ - ╔▄▄▓██▓▄¿ . - - ]:├╣██▀▀▀▀▀▒Q¿.¿{ )= - <─Ω╚█░▓███'──h⌂▓██╡██▌Ü─4 - `┌ ▐▓█▄▓██╟≤¡yQ▄███▓█▌▐▓M - ^. └▀▀▄▓██▄▒▄▄╬▄████▀ └▀╙` - «-'''█▓▓█▓██▓██▓▓██└``'⌐ - #╛ µ:▓▄;xφ(⌠ █ - ╗µ ~╙'` `┌█╣ - ╝Ñ. '' ' L▀▀ - ~ , - ^ - ÷ ─. - N. ∩ts ) - ∩ ²⌠ ` ─ - (@ ~▒▒ . - ` └▀/ .>▀▀ : - ⌐ ,⌐ - - - - Artemis - Version 0.5.1 - Copyright (c) 2016 Frostbane Ac - Apache-2.0, APL-1.0, 0BSD Licensed. ? - www.??.com - ?? inspired script. - -""" - -from mercurial import hg, util, commands, cmdutil -import sys, os, time, random, mailbox, glob, socket, ConfigParser, re -from properties import ArtemisProperties -from artemis import Artemis - -__author__ = 'frostbane' -__date__ = '2016/05/24' - - -class ArtemisFind: - commands = [ - # dashed options will be resolved to underscores - # case-sensitive => case_sensitive - ('p', 'property', "subject", 'Issue property to match. ' - '[state, from, subject, date, ' - 'priority, resolution, etc..].' - '(e.g. hg isearch -p from me)'), - ('n', 'no-property', None, 'Do not match the property. Use ' - 'with --message to search only ' - 'the message. The --property will ' - 'be ignored.'), - ('m', 'message', None, 'Search the message. If no match is ' - 'found it will then search the ' - 'property for a match. Use ' - '--no-property or use a blank ' - 'property to ignore the property ' - 'search.' - '(e.g. hg isearch -mp "" "the bug")'), - ('c', 'case-sensitive', None, 'Case sensitive search.'), - ('r', 'regex', None, 'Use regular expressions. ' - 'Exact option will be ignored.' - '(e.g. hg isearch -rmn "todo *(:)?"'), - ('e', 'exact', None, 'Use exact comparison. ' - 'Like comparison is used if exact is' - 'uspecified.'), - ] - usage = 'hg ifind [OPTIONS] QUERY' - - ui = None - repo = None - opts = [] - - def __init__(self): - pass - - def __is_hit(self, query, search_string): - opts = self.opts - - exact_comp = opts["exact"] and not opts["regex"] - regexp_comp = opts["regex"] - - if regexp_comp: - re_pattern = re.compile(query) - return re_pattern.search(search_string) - elif exact_comp: - return query == search_string - else: - return query in search_string - - def __get_payload(self, mbox, key): - # todo move to Artemis class since this is static, and can - # be possible reused in other classes - payload = "" - - for part in mbox[key].walk(): - ctype = part.get_content_type() - maintype, subtype = ctype.split('/', 1) - - if maintype == 'multipart': - continue - - if ctype == 'text/plain': - payload = part.get_payload().strip() - - return payload - - def __search_payload(self, mbox, query, case_sens): - keys = mbox.keys() - for key in keys: - payload = self.__get_payload(mbox, key) - - if not payload: - continue - - if not case_sens: - payload = payload.lower() - - if self.__is_hit(query, payload): - return True - - return False - - def __search_property(self, mbox, query, case_sens): - query_filter = self.opts["property"] - - root = Artemis.find_root_key(mbox) - search_string = mbox[root][query_filter] - - # non existing property - if not search_string: - return False - - if not case_sens: - search_string = search_string.lower() - - if self.__is_hit(query, search_string): - return True - - return False - - def __search_issues(self, query): - case_sens = self.opts["case_sensitive"] - search_payload = self.opts["message"] - no_property = self.opts["no_property"] - - if not case_sens: - query = query.lower() - - hits = [] - - mboxes = Artemis.get_all_mboxes(self.ui, self.repo) - for mbox in mboxes: - has_hit = False - - if search_payload: - has_hit = self.__search_payload(mbox, - query, - case_sens) - - if has_hit: - hits.append(mbox.issue) - # if the message has a hit there is no need to - # continue searching for a hit with the property - continue - - if no_property: - # ignore the property search - continue - - if self.__search_property(mbox, query, case_sens): - hits.append(mbox.issue) - - return hits - - def __show_results(self, issues): - ui = self.ui - repo = self.repo - - for issue in issues: - mbox = mailbox.Maildir(issue, - factory=mailbox.MaildirMessage) - root = Artemis.find_root_key(mbox) - # print mbox.items() - # print mbox.keys() - # print mbox.values() - - num_replies = str(len(mbox.keys()) - 1) - # print mbox[root]["message-id"] - ui.write("%s (%s) [%s]: %s\n" % - ( - Artemis.get_issue_id(ui, repo, issue), - num_replies.rjust(3, " "), - mbox[root]["state"], - mbox[root]["subject"]) - ) - - def find(self, ui, repo, query, **opts): - """Shows a list of issues matching the specified QUERY""" - self.opts = opts - self.ui = ui - self.repo = repo - - issues = self.__search_issues(query) - self.__show_results(issues) diff -r 979145ac3ccc -r 6368495f10a5 artemis/list.py --- a/artemis/list.py Wed Mar 15 13:12:28 2017 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,218 +0,0 @@ -#!/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 979145ac3ccc -r 6368495f10a5 artemis/main.py --- a/artemis/main.py Wed Mar 15 13:12:28 2017 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,50 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -from mercurial import hg, util, commands, cmdutil -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 -from find import ArtemisFind - -__author__ = 'frostbane' -__date__ = '2016/03/02' - - -cmdtable = {} -command = cmdutil.command(cmdtable) - -@command('ifind', ArtemisFind.commands, ArtemisFind.usage) -def find(ui, repo, id=None, **opts): - '''find issues''' - return ArtemisFind().find(ui, repo, id, **opts) - -@command('ishow', ArtemisShow.commands, ArtemisShow.usage) -def show(ui, repo, id=None, **opts): - '''show issue details''' - return ArtemisShow().show(ui, repo, id, **opts) - -@command('ilist', ArtemisList.commands, ArtemisList.usage) -def list(ui, repo, id=None, **opts): - '''list issues''' - return ArtemisList().list(ui, repo, **opts) - -@command('iadd', ArtemisAdd.commands, ArtemisAdd.usage) -def add(ui, repo, id=None, **opts): - '''add / edit issues''' - return ArtemisAdd().add(ui, repo, id, **opts) - -if __name__ == "__main__": - pass diff -r 979145ac3ccc -r 6368495f10a5 artemis/properties.py --- a/artemis/properties.py Wed Mar 15 13:12:28 2017 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,32 +0,0 @@ -#!/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 979145ac3ccc -r 6368495f10a5 artemis/show.py --- a/artemis/show.py Wed Mar 15 13:12:28 2017 -0400 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,127 +0,0 @@ -#!/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