--- a/.gitignore Wed Oct 12 09:46:14 2016 -0700
+++ b/.gitignore Thu Dec 01 11:23:31 2016 -0800
@@ -1,5 +1,7 @@
+*.pyo
*.pyc
artemis.egg-info/
build/
dist/
-
+.idea/*
+pycharm-debug.egg
--- a/.hgignore Wed Oct 12 09:46:14 2016 -0700
+++ b/.hgignore Thu Dec 01 11:23:31 2016 -0800
@@ -1,5 +1,10 @@
syntax:glob
+*.pyo
*.pyc
artemis.egg-info/
build/
dist/
+.idea/*
+pycharm-debug.egg
+syntax: glob
+artemis.iml
--- a/README Wed Oct 12 09:46:14 2016 -0700
+++ b/README Thu Dec 01 11:23:31 2016 -0800
@@ -128,70 +128,100 @@
--------
`iadd` ``[ID] [COMMENT]``
- Add an issue, or a comment to an existing issue or comment. The comment is
- recorded as a reply to the particular message. `iadd` is the only command
- that changes the state of the repository (by adding the new issue files to
- the list of tracked files or updating some of them), however, it does not
- perform an actual commit unless explicitly asked to do so.
+> Add an issue, or a comment to an existing issue or comment. The comment is
+> recorded as a reply to the particular message. `iadd` is the only command
+> that changes the state of the repository (by adding the new issue files to
+> the list of tracked files or updating some of them), however, it does not
+> perform an actual commit unless explicitly asked to do so.
- `-p`, `--property`
- update a property of the issue ``ID``, e.g. ``-p state=resolved -p resolution=fixed``
+
+options:
- `-a`, `--attach`
- attach a file to the message, e.g. ``-a filename1 -a filename2``
-
- `-n`, `--no-property-comment`
- do not launch an editor to record a comment (useful if only changing
- properties)
+| | | 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 |
- `-m`, `--message`
- use ``text`` as an issue subject
-
- `-c`, `--commit`
- commit the issue after the addition (all changes to the issue will be
- committed)
+[+] marked option can be specified multiple times
`ilist`
- List issues.
+> List issues associated with the project
- `-a`, `--all`
- list all issues (not just the `new` ones)
+options:
- `-p`, `--property`
- list issues with specific property values, e.g.
- ``-p state=resolved -p category=documentation``;
- if no property value is provided (e.g. ``-p category``), lists all
- possible values for that property (among the issues that satisfy the
- rest of the criteria)
+| | | 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*) |
- `-o`, `--order`
- order of the issues; choices: "new" (date submitted), "latest" (date of
- the most recent message)
-
- `-d`, `--date`
- restrict to issues matching the given date, e.g. ``-d ">1/1/2008"``
-
- `-f`, `--filter`
- restrict to a predefined filter, see Filters_ below
+[+] marked option can be specified multiple times
`ishow` ``[ID] [COMMENT]``
- Show an issue or a comment.
+> Shows issue ID, or possibly its comment COMMENT
+
+options:
- `-a`, `--all`
- list all comments to an issue (i.e. not just a single message, and a
- thread of subjects of its replies)
+| | | 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
+
- `-s`, `--skip`
- in the output skip lines of the messages starting with the given
- substring, defaults to ``>``
+`ifind`
+
+> Shows a list of issues matching the specified QUERY
+
+options:
- `-x`, `--extract`
- extract attachments (given their numbers)
+| | | description |
+|-----|-------------------|-------------------------------------------|
+| -p | --property VALUE | issue property to match |
+| | | [state, from, subject, date, priority, |
+| | | [resolution, ticket, etc..] |
+| | | (default: subject) |
+| -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 |
- `--mutt`
- use ``mutt`` to show issue
+[+] marked option can be specified multiple times
Filters
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/README.md Thu Dec 01 11:23:31 2016 -0800
@@ -0,0 +1,365 @@
+# 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/)
--- a/artemis/__init__.py Wed Oct 12 09:46:14 2016 -0700
+++ b/artemis/__init__.py Thu Dec 01 11:23:31 2016 -0800
@@ -1,3 +1,3 @@
-from artemis import *
+from main import *
-__version__ = '0.5.0'
+__version__ = '0.5.1'
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/artemis/add.py Thu Dec 01 11:23:31 2016 -0800
@@ -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)
--- a/artemis/artemis.py Wed Oct 12 09:46:14 2016 -0700
+++ b/artemis/artemis.py Thu Dec 01 11:23:31 2016 -0800
@@ -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
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/artemis/find.py Thu Dec 01 11:23:31 2016 -0800
@@ -0,0 +1,218 @@
+#!/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)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/artemis/list.py Thu Dec 01 11:23:31 2016 -0800
@@ -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)
+
+
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/artemis/main.py Thu Dec 01 11:23:31 2016 -0800
@@ -0,0 +1,42 @@
+#!/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
+from find import ArtemisFind
+
+__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)),
+ 'ifind' : (ArtemisFind().find,
+ ArtemisFind.commands,
+ _(ArtemisFind.usage)),
+}
+
+if __name__ == "__main__":
+ pass
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/artemis/properties.py Thu Dec 01 11:23:31 2016 -0800
@@ -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)
--- /dev/null Thu Jan 01 00:00:00 1970 +0000
+++ b/artemis/show.py Thu Dec 01 11:23:31 2016 -0800
@@ -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