sirex avatar sirex committed 7116583 Draft

Initial.

Comments (0)

Files changed (19)

+syntax: glob
+
+*.pyc
+*.egg-info
+Project name
+============
+
+"Distributed issue tracker" in Latin is "Distribuit EXitus ELIT" from which
+name Dexelit is made.
+
+Directory structure
+===================
+
+One ticket, per file, but also, should be possible to write many tickets to one
+file, and then using a tool, this file will be parsed and split in to many
+tickets.
+
+Example of file structure with files, that contains one ticket::
+
+    '
+    `-- my-projects
+        |-- my-ticket.rst
+        `-- other-ticket.rst
+
+Multiple tickets from one file
+==============================
+
+Example of single file, with many tickets, that can be split in to many tickets
+using a tool::
+
+    My ticket
+    =========
+
+    :created: 2012-01-01 10:00
+    :author: sirex
+    :type: ticket
+
+    Ticket description.
+
+    Other ticket
+    ============
+
+    :created: 2012-01-01 10:30
+    :author: sirex
+    :type: ticket
+
+    Other ticket description.
+
+To split this file::
+
+    $ dexelit split filename.rst
+
+It is possible to keep many tickets even in hierarchical structure in one file,
+example::
+
+    Ticket 1
+    ========
+
+    :type: ticket
+
+    Description.
+
+    Sub Ticket 2
+    ------------
+
+    :type: ticket
+
+    Description.
+
+    Sub Sub Ticket 3
+    ''''''''''''''''
+
+    :type: ticket
+
+    Description.
+
+Tickets will be recognized by ``:type: ticket`` field, and hierarchy will be
+taken from headings.
+
+Categories
+==========
+
+Tickets can have categories::
+
+    '
+    `-- my-project
+        |-- my-ticket.rst
+        |-- feature-tickets
+        |   |-- index.rst
+        |   `-- a-feature-ticket.rst
+        `-- other-ticket.rst
+
+``index.rst`` file describes category as one ticket.
+
+Updating multiple tickets
+=========================
+
+It is possible, using a tool to manage multiple tickets at once, for example::
+
+    $ dexelit manage ./my-project/
+
+This command generates editable file, that will be opened with your favorite
+text editor and will content looks like this::
+
+    # Available actions:
+    #
+    # * delete - delete following tickets, deleted tickets still stays under
+    #   revision control an can be restored if needed.
+    #
+    # * priority=<priority> - set ticket priority to <priority>, <priority>
+    #   must be integer number.
+    #
+    # * close - mark tickets as closed.
+    #
+    # * assign=<user> - assigne tickets to a user, if <user> is empty, then
+    #   tickets will be unassigned.
+
+    action: close
+        my-ticket.rst
+
+    feature-tickets/index.rst
+
+    action: priority=100 assign=sirex
+        feature-tickets/a-feature-ticket.rst
+        other-ticket.rst
+
+All indented actions will be updated by specified action.
+
+Archive
+=======
+
+Old and closed tickets can be deleted automatically using this command::
+
+    $ dexelit archive
+
+Import
+======
+
+Tickets can be imported from Trac::
+
+    $ dexelit pull trac+http://issues.myproject.com/
+
+Export
+======
+
+You can export all modifications to an external issue tracker using this
+command::
+
+    $ dexelit push trac+http://issues.myproject.com/
+
+You can only export modifications if you have previously imported from that
+source. After import, dexelit tracks last update info, and only exports what is
+modified locally.
+
+Build-in web server
+===================
+
+Ticket tracker has build-in web server based on Django, that displays all
+tickets in web browser.
+
+Integration with gTimeLog
+=========================
+
+It is possible to integrate ticket tracker with gTimeLog, for time management.
+To enable gTimeLog support, you need to enable it in ~/.dexelitrc file.
+
+Ticket tracker simply checks all gTimeLog entries and tries to match them with
+projects.
+
+Also it is possible to record time using this command::
+
+    $ cd /path/to/my-project/
+    $ dexelit log ticket.rst
+
+This command will add line to gTimeLog log file::
+
+    2012-07-12 13:39: my-project: ticket
+
+Also calculates whole spent time for this ticket and updates ``:spent:`` field.
+
+Reviewers
+=======
+
+Ticket can have reviewers, that are responsible to accept ticket if it is
+implemented correctly.
+
+Example::
+
+    My ticket
+    =========
+
+    :author: sirex
+    :reviewers: laurynas marius
+    :type: ticket
+
+If ``laurynas`` accepts this ticket, then ``:reviewers:`` line should look like
+this::
+
+    :reviewers: laurynas=ok marius
+    
+Comments
+========
+
+Comment can be written as ``.. note::`` blocks everywhere in ticket
+description.
+
+
+Project description file
+========================
+
+``index.rst`` file in project root is used as project description file.
+
+
+Project repositories
+====================
+
+Projects can have many repositories with one default. All repositories can be
+specified in ``index.rst`` project file this way::
+
+    Repositories
+    ============
+
+    Default project repository
+    --------------------------
+
+    :repository: http://hg.example.com/default
+    :default: yes
+    :type: repository=mercurial
+
+    My branch
+    ---------
+
+    :repository: http://hg.example.com/my-branch
+    :type: repository=mercurial
+
+    Description of this repository.

Empty file added.

dexelit/cli/__init__.py

+import os
+
+from ..initialize import initialize
+initialize()
+
+from ..db.models import sync
+
+
+def main():
+    sync(os.getcwd())
+    print('hello')

Empty file added.

dexelit/db/models.py

+import os
+import os.path
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from ..exceptions import RepositoryException
+from ..markup import MARKUPS
+from ..markup import iter_nodes
+from ..markup.base import INDEX
+
+from mptt.models import MPTTModel, TreeForeignKey
+
+
+def get_index_file(path):
+    for ext in MARKUPS.keys():
+        index = '%s.%s' % (INDEX, ext)
+        if os.path.exists(os.path.join(path, index)):
+            return index
+    return None
+
+
+def sync_file(parent, filename):
+    first = None
+    x, ext = os.path.splitext(filename)
+    ext = ext.lstrip('.')
+    for title, fields, content, parent in iter_nodes(ext, filename):
+        print('%s [ %s ]' % (title, fields['type']))
+        node = Ticket()
+        node.title = title
+        node.update_from_fields(fields)
+        first = first or node
+
+
+def sync(path):
+    index = get_index_file(path)
+    if not index:
+        raise RepositoryException(
+                _("Can't find project index file in %s." % path))
+
+    parents = {'': index}
+    extensions = MARKUPS.keys()
+    path = os.path.abspath(path) + os.path.sep
+    root_strip_pos = len(path)
+    for root, dirs, files in os.walk(path):
+        relroot = root[root_strip_pos:]
+
+        # Sync index file
+        index = parents[relroot]
+        parent = sync_file(None, os.path.join(root, index))
+        files.remove(index)
+
+        # Sync other files
+        for f in files:
+            name, ext = os.path.splitext(f)
+            ext = ext.lstrip('.')
+            if ext in extensions:
+                filename = os.path.join(root, f)
+                sync_file(parent, filename)
+
+        # Do not visit direcotires, that does not have INDEX file.
+        skip_dirs = []
+        for d in dirs:
+            index = get_index_file(os.path.join(root, d))
+            if index:
+                parents[os.path.join(relroot, d)] = index
+            else:
+                skip_dirs.append(d)
+        for d in skip_dirs:
+            dirs.remove(d)
+
+
+class Profile(models.Model):
+    slug = models.SlugField(unique=True)
+    name = models.CharField(max_length=100)
+
+
+class ProjectManager(models.Manager):
+    def get_or_create_from_data(self, data):
+        pass
+
+
+class Project(MPTTModel):
+    slug = models.SlugField(unique=True)
+    title = models.CharField(max_length=100)
+
+    # Client who pays for this project.
+    client = models.ForeignKey(Profile, null=True, blank=True,
+                               related_name='client_project_set')
+
+    # Company responsible for implementing this project.
+    company = models.ForeignKey(Profile, null=True, blank=True,
+                               related_name='company_project_set')
+
+    # Local path to project directory, this directory must be root of VCS, that
+    # contains index.rst file.
+    path = models.CharField(max_length=255)
+
+    parent = TreeForeignKey('self', null=True, blank=True,
+                            related_name='children')
+
+    objects = ProjectManager()
+
+    class MPTTMeta:
+        order_insertion_by = ['title']
+
+    def update_from_fields(self, fields):
+        self.slug = fields.get('slug')
+
+
+class Repository(models.Model):
+    project = models.ForeignKey(Project)
+
+    # Repository path, can be URL.
+    path = models.CharField(max_length=255)
+
+    # Specifies default project repository.
+    # Only one repository per project can be default.
+    default = models.BooleanField()
+
+
+ARCHIVED = 0
+OPENED = 1
+ASSIGNED = 1
+CLOSED = 2
+DUPLICATE = 3
+WONTFIX = 4
+DONE = 5
+TICKET_STATUSES = (
+    (ARCHIVED, _('Archived')),
+    (OPENED, _('Opened')),
+    (CLOSED, _('Closed')),
+    (DUPLICATE, _('Duplicate')),
+    (WONTFIX, _("Won't fix")),
+    (DONE, _('Done')),
+)
+
+
+class Ticket(MPTTModel):
+    project = models.ForeignKey(Project)
+
+    slug = models.SlugField(unique=True)
+    title = models.CharField(max_length=100)
+
+    status = models.PositiveSmallIntegerField(choices=TICKET_STATUSES)
+    priority = models.SmallIntegerField()
+
+    # If ticket is archived (removed from repository), this field specifies last
+    # revision, before ticket file was archived.
+    archive_rev = models.ForeignKey('Revision')
+
+    duplicate_of = models.ForeignKey('self', related_name='duplicates')
+
+    # Ticket author, who initially created this ticket.
+    author = models.ForeignKey(Profile,
+                               related_name='author_ticket_set')
+
+    # Ticket mentor is a person who makes sure, that this ticket is really
+    # implemented. If mentor is assigned, then this ticket can be closed only
+    # by mentor. If more than one mentor is assigned, then ticket can be closed
+    # only if all mentors accepts this ticket.
+    mentors = models.ManyToManyField(Profile, null=True, blank=True,
+                                     related_name='mentor_ticket_set')
+
+    # Owner, who is responsible for this ticket.
+    owner = models.ForeignKey(Profile, null=True, blank=True,
+                              related_name='owner_ticket_set')
+
+    # Ticket can be assigned to more that one profile, this way, one of
+    # assignees can become owner of ticket.
+    assigned_to = models.ManyToManyField(Profile, null=True, blank=True,
+                                         related_name='assigned_ticket_set')
+
+    # This ticket is expected to be finished in <expected> number of hours.
+    expected = models.PositiveIntegerField()
+    # This ticket currently is estimated to be finished in <estimate> number of
+    # hours.
+    estimate = models.PositiveIntegerField()
+    # Number of hours that are already <spent> working on this ticket.
+    spent = models.PositiveIntegerField()
+
+    parent = TreeForeignKey('self', null=True, blank=True,
+                            related_name='children')
+
+    class MPTTMeta:
+        order_insertion_by = ['title']
+
+    def update_from_fields(self, fields):
+        self.slug = fields.get('slug')
+
+
+class Fields(models.Model):
+    tickets = models.ForeignKey(Ticket)
+    key = models.SlugField()
+    value = models.CharField(max_length=255)
+
+
+class Revision(MPTTModel):
+    revno = models.CharField(max_length=32, unique=True)
+    author = models.ForeignKey(Profile)
+
+    # Updated tickets by this revision.
+    tickets = models.ManyToManyField(Ticket, null=True, blank=True)
+
+    message = models.TextField()
+
+    # Parent comment, that this comment is replaying to.
+    parent = TreeForeignKey('self', null=True, blank=True,
+                            related_name='children')
+
+    class MPTTMeta:
+        order_insertion_by = ['revno']

dexelit/exceptions.py

+class RepositoryException(Exception):
+    pass

dexelit/initialize.py

+import os.path
+
+from django.conf import settings
+from django.core.management import call_command
+from django.core.management import setup_environ
+
+
+def initialize():
+    settings.configure(
+        DEBUG=True,
+        DATABASES={
+            'default': {
+                'ENGINE': 'django.db.backends.sqlite3',
+                'NAME': 'dexelit.db',
+            }
+        },
+        INSTALLED_APPS=(
+            'mptt',
+            'dexelit.db',
+        ),
+    )
+
+    setup_environ(settings)
+
+    db = settings.DATABASES['default']
+    if db['ENGINE'] == 'django.db.backends.sqlite3':
+        if not os.path.exists(db['NAME']):
+            print('Database does not exists. Running syncdb.')
+            call_command('syncdb', interactive=False)

dexelit/markup/__init__.py

+from .rst import RstMarkup
+
+
+MARKUPS = {
+    'rst': RstMarkup,
+}
+
+def iter_nodes(ext, filename):
+    if ext in MARKUPS:
+        parser = RstMarkup(filename)
+        return parser.iter_nodes()

dexelit/markup/base.py

+INDEX = 'index'
+
+class Markup(object):
+    def iter_nodes(self):
+        """Iterates over all nodes.
+
+        Each node is a heading with specified ``:type:`` field. If first, top
+        level heading does not have ``:type:`` field, then default ``page``
+        type is used.
+        """
+        raise NotImplemented

dexelit/markup/rst.py

+import os.path
+
+from docutils import io
+from docutils import nodes
+from docutils.core import publish_doctree
+
+from ..utils import slugify
+
+from .base import INDEX
+from .base import Markup
+
+
+class RstMarkup(Markup):
+    def __init__(self, filename):
+        self.filename = filename
+        with open(filename) as f:
+            self.document = publish_doctree(f, source_class=io.FileInput)
+
+    def read_fields(self, title, field_list=None):
+        fields = {}
+
+        if field_list:
+            for key, value in field_list:
+                fields[key.astext()] = value.astext()
+
+        if 'slug' not in fields:
+            fields['slug'] = slugify(title)
+
+        return fields
+
+    def read_content(self, content_nodes, offset):
+        children = []
+        content = content_nodes[:offset]
+        for node in content_nodes[offset:]:
+            if isinstance(node, nodes.section):
+                child = self.read_section(node)
+                if child: # skip node if this node has own type
+                    children.append(child)
+                    continue
+            content.append(node)
+
+        return (content, children)
+
+    def read_section(self, section):
+        if (not isinstance(section[0], nodes.title) or
+            not isinstance(section[1], nodes.field_list)):
+            return None
+
+        title = section[0].astext()
+        fields = self.read_fields(title, section[1])
+
+        if 'type' not in fields:
+            return None
+
+        content, children = self.read_content(section, 2)
+        return ((title, fields, content), children)
+
+    def read_document(self, document):
+        if isinstance(document[0], nodes.title):
+            title = document[0].astext()
+        else:
+            return None
+
+        if isinstance(document[1], nodes.docinfo):
+            fields = self.read_fields(title, document[1])
+            offset = 2
+        else:
+            fields = self.read_fields(title)
+            offset = 1
+
+        content, children = self.read_content(document, offset)
+        return ((title, fields, content), children)
+
+    def iter_children(self, parent=None, items=None):
+        for item, children in items:
+            title, fields, content = item
+            yield title, fields, content, parent
+            for item in self.iter_children(fields['slug'], children):
+                yield item
+
+    def iter_nodes(self, parent=None):
+        item, children = self.read_document(self.document)
+        title, fields, content = item
+
+        yield title, fields, content, parent
+
+        for item in self.iter_children(fields['slug'], children):
+            yield item
+import unidecode
+
+from django.template.defaultfilters import slugify as django_slugify
+
+
+def slugify(title):
+    return django_slugify(unidecode.unidecode(title))

dexelit/vcs/__init__.py

+from .mercurial import Mercurial
+from .subversion import Subversion
+
+
+VCS = {
+    'mercurial': Mercurial,
+    'subversion': Subversion,
+}
+
+
+def parse_markup():
+    pass

dexelit/vcs/base.py

+class VCS(object):
+    pass

dexelit/vcs/mercurial.py

+from .base import VCS
+
+
+class Mercurial(VCS):
+    pass

dexelit/vcs/subversion.py

+from .base import VCS
+
+
+class Subversion(VCS):
+    pass

Empty file added.

Empty file added.

+from setuptools import setup, find_packages
+
+setup(name='dexelit',
+      maintainer='Programmers of Vilnius',
+      maintainer_email='sirex@pov.lt',
+      description='Distributed issue tracker',
+      version='0.1',
+      license='GPL',
+      packages=find_packages(),
+      include_package_data=True,
+      zip_safe=False,
+      install_requires=[
+          'docutils',
+          'unidecode',
+          'django',
+          'django-mptt',
+      ],
+      entry_points='''
+      [console_scripts]
+      dexelit = dexelit.cli:main
+      ''')
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.