Commits

Joachim Hoessler  committed 66f599d

Another implementation for links between tickets.

It implements configurable relationships between tickets in a rather straight forward way. It includes
* a new database that stores source, destination and the type of a link
* a new extension point in the ticket api that allows other components to provide information on links
* an component that uses this extension point to create links as specified in trac.ini in the form of
{{{
[trac-links]
blocker = blocking, blockedby
blocking.label = Blocking
blockedby.label = Blocked By
blocker.validator = no_cycle
}}}
* the implementation supports bidirectional links (the opposite direction is set automatically), unidirectional links and reflective links
* validation is implemented through the ITicketManipulater interfaces
* links are edited and rendered in the ticket view as text fields in the format "#1, #2, #5"

The basic stuff works so far. However, it doesn't implement ticket "blocking" or other workflow related impact of links yet, but this could be easily added, either in this implementation, or in a plugin. Also, any visualization of dependencies isn't included yet.

I know that there are suggestions in this ticket thread for a more advanced approach, but I think this implementation should be sufficient for most of the use cases.

Contributed on #31 by Joachim Hoessler <hoessler@gmail.com>

  • Participants
  • Parent commits 4697bc4
  • Branches ticket-links

Comments (0)

Files changed (11)

-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-#
-# Copyright (C) 2003-2009 Edgewall Software
-# All rights reserved.
-#
-# This software is licensed as described in the file COPYING, which
-# you should have received as part of this distribution. The terms
-# are also available at http://trac.edgewall.org/wiki/TracLicense.
-#
-# This software consists of voluntary contributions made by many
-# individuals. For the exact contribution history, see the revision
-# history and logs, available at http://trac.edgewall.org/log/.
-
-from setuptools import setup, find_packages
-
-extra = {}
-
-try:
-    import babel
-    extra['message_extractors'] = {
-        'trac': [
-            ('**.py',                'python', None),
-            ('**/templates/**.html', 'genshi', None),
-            ('**/templates/**.txt',  'genshi', {
-                'template_class': 'genshi.template:TextTemplate'
-            })
-        ],
-    }
-except ImportError:
-    pass
-
-setup(
-    name = 'Trac',
-    version = '0.12',
-    description = 'Integrated SCM, wiki, issue tracker and project environment',
-    long_description = """
-Trac is a minimalistic web-based software project management and bug/issue
-tracking system. It provides an interface to the Subversion revision control
-systems, an integrated wiki, flexible issue tracking and convenient report
-facilities.
-""",
-    author = 'Edgewall Software',
-    author_email = 'info@edgewall.com',
-    license = 'BSD',
-    url = 'http://trac.edgewall.org/',
-    download_url = 'http://trac.edgewall.org/wiki/TracDownload',
-    classifiers = [
-        'Environment :: Web Environment',
-        'Framework :: Trac',
-        'Intended Audience :: Developers',
-        'License :: OSI Approved :: BSD License',
-        'Operating System :: OS Independent',
-        'Programming Language :: Python',
-        'Topic :: Software Development :: Bug Tracking',
-        'Topic :: Software Development :: Version Control',
-    ],
-
-    packages = find_packages(exclude=['*.tests']),
-    package_data = {
-        '': ['templates/*'],
-        'trac': ['htdocs/*.*', 'htdocs/README', 'htdocs/js/*', 'htdocs/css/*',
-                 'htdocs/guide/*', 'locale/*.*', 'locale/*/LC_MESSAGES/*.*'],
-        'trac.wiki': ['default-pages/*'],
-        'trac.ticket': ['workflows/*.ini'],
-    },
-
-    test_suite = 'trac.test.suite',
-    zip_safe = False,
-
-    install_requires = [
-        'setuptools>=0.6b1',
-        'Genshi>=0.6dev-r960'
-    ],
-    extras_require = {
-        'Babel': ['Babel>=0.9.4'],
-        'Pygments': ['Pygments>=0.6'],
-        'reST': ['docutils>=0.3'],
-        'SilverCity': ['SilverCity>=0.9.4'],
-        'Textile': ['textile>=2.0'],
-    },
-
-    entry_points = """
-        [console_scripts]
-        trac-admin = trac.admin.console:run
-        tracd = trac.web.standalone:main
-
-        [trac.plugins]
-        trac.about = trac.about
-        trac.admin.console = trac.admin.console
-        trac.admin.web_ui = trac.admin.web_ui
-        trac.attachment = trac.attachment
-        trac.db.mysql = trac.db.mysql_backend
-        trac.db.postgres = trac.db.postgres_backend
-        trac.db.sqlite = trac.db.sqlite_backend
-        trac.mimeview.enscript = trac.mimeview.enscript
-        trac.mimeview.patch = trac.mimeview.patch
-        trac.mimeview.php = trac.mimeview.php
-        trac.mimeview.pygments = trac.mimeview.pygments[Pygments]
-        trac.mimeview.rst = trac.mimeview.rst[reST]
-        trac.mimeview.silvercity = trac.mimeview.silvercity[SilverCity]
-        trac.mimeview.txtl = trac.mimeview.txtl[Textile]
-        trac.prefs = trac.prefs.web_ui
-        trac.search = trac.search.web_ui
-        trac.ticket.admin = trac.ticket.admin
-        trac.ticket.query = trac.ticket.query
-        trac.ticket.report = trac.ticket.report
-        trac.ticket.roadmap = trac.ticket.roadmap
-        trac.ticket.web_ui = trac.ticket.web_ui
-        trac.timeline = trac.timeline.web_ui
-        trac.versioncontrol.admin = trac.versioncontrol.admin
-        trac.versioncontrol.svn_fs = trac.versioncontrol.svn_fs
-        trac.versioncontrol.web_ui = trac.versioncontrol.web_ui
-        trac.web.auth = trac.web.auth
-        trac.wiki.admin = trac.wiki.admin
-        trac.wiki.macros = trac.wiki.macros
-        trac.wiki.web_ui = trac.wiki.web_ui
-    """,
-
-    **extra
-)
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2003-2009 Edgewall Software
+# All rights reserved.
+#
+# This software is licensed as described in the file COPYING, which
+# you should have received as part of this distribution. The terms
+# are also available at http://trac.edgewall.org/wiki/TracLicense.
+#
+# This software consists of voluntary contributions made by many
+# individuals. For the exact contribution history, see the revision
+# history and logs, available at http://trac.edgewall.org/log/.
+
+from setuptools import setup, find_packages
+
+extra = {}
+
+try:
+    import babel
+    extra['message_extractors'] = {
+        'trac': [
+            ('**.py',                'python', None),
+            ('**/templates/**.html', 'genshi', None),
+            ('**/templates/**.txt',  'genshi', {
+                'template_class': 'genshi.template:TextTemplate'
+            })
+        ],
+    }
+except ImportError:
+    pass
+
+setup(
+    name = 'Trac',
+    version = '0.12',
+    description = 'Integrated SCM, wiki, issue tracker and project environment',
+    long_description = """
+Trac is a minimalistic web-based software project management and bug/issue
+tracking system. It provides an interface to the Subversion revision control
+systems, an integrated wiki, flexible issue tracking and convenient report
+facilities.
+""",
+    author = 'Edgewall Software',
+    author_email = 'info@edgewall.com',
+    license = 'BSD',
+    url = 'http://trac.edgewall.org/',
+    download_url = 'http://trac.edgewall.org/wiki/TracDownload',
+    classifiers = [
+        'Environment :: Web Environment',
+        'Framework :: Trac',
+        'Intended Audience :: Developers',
+        'License :: OSI Approved :: BSD License',
+        'Operating System :: OS Independent',
+        'Programming Language :: Python',
+        'Topic :: Software Development :: Bug Tracking',
+        'Topic :: Software Development :: Version Control',
+    ],
+
+    packages = find_packages(exclude=['*.tests']),
+    package_data = {
+        '': ['templates/*'],
+        'trac': ['htdocs/*.*', 'htdocs/README', 'htdocs/js/*', 'htdocs/css/*',
+                 'htdocs/guide/*', 'locale/*.*', 'locale/*/LC_MESSAGES/*.*'],
+        'trac.wiki': ['default-pages/*'],
+        'trac.ticket': ['workflows/*.ini'],
+    },
+
+    test_suite = 'trac.test.suite',
+    zip_safe = False,
+
+    install_requires = [
+        'setuptools>=0.6b1',
+        'Genshi>=0.6dev-r960'
+    ],
+    extras_require = {
+        'Babel': ['Babel>=0.9.4'],
+        'Pygments': ['Pygments>=0.6'],
+        'reST': ['docutils>=0.3'],
+        'SilverCity': ['SilverCity>=0.9.4'],
+        'Textile': ['textile>=2.0'],
+    },
+
+    entry_points = """
+        [console_scripts]
+        trac-admin = trac.admin.console:run
+        tracd = trac.web.standalone:main
+
+        [trac.plugins]
+        trac.about = trac.about
+        trac.admin.console = trac.admin.console
+        trac.admin.web_ui = trac.admin.web_ui
+        trac.attachment = trac.attachment
+        trac.db.mysql = trac.db.mysql_backend
+        trac.db.postgres = trac.db.postgres_backend
+        trac.db.sqlite = trac.db.sqlite_backend
+        trac.mimeview.enscript = trac.mimeview.enscript
+        trac.mimeview.patch = trac.mimeview.patch
+        trac.mimeview.php = trac.mimeview.php
+        trac.mimeview.pygments = trac.mimeview.pygments[Pygments]
+        trac.mimeview.rst = trac.mimeview.rst[reST]
+        trac.mimeview.silvercity = trac.mimeview.silvercity[SilverCity]
+        trac.mimeview.txtl = trac.mimeview.txtl[Textile]
+        trac.prefs = trac.prefs.web_ui
+        trac.search = trac.search.web_ui
+        trac.ticket.admin = trac.ticket.admin
+        trac.ticket.query = trac.ticket.query
+        trac.ticket.report = trac.ticket.report
+        trac.ticket.roadmap = trac.ticket.roadmap
+        trac.ticket.web_ui = trac.ticket.web_ui
+        trac.ticket.links = trac.ticket.links
+        trac.timeline = trac.timeline.web_ui
+        trac.versioncontrol.admin = trac.versioncontrol.admin
+        trac.versioncontrol.svn_fs = trac.versioncontrol.svn_fs
+        trac.versioncontrol.web_ui = trac.versioncontrol.web_ui
+        trac.web.auth = trac.web.auth
+        trac.wiki.admin = trac.wiki.admin
+        trac.wiki.macros = trac.wiki.macros
+        trac.wiki.web_ui = trac.wiki.web_ui
+    """,
+
+    **extra
+)

File trac/db_default.py

 from trac.db import Table, Column, Index
 
 # Database version identifier. Used for automatic upgrades.
-db_version = 21
+db_version = 22
 
 def __mkreports(reports):
     """Utility function used to create report data in same syntax as the
         Column('ticket', type='int'),
         Column('name'),
         Column('value')],
+    Table('ticket_links', key=('source', 'destination', 'type'))[
+        Column('source', type='int'),
+        Column('destination', type='int'),
+        Column('type')],
     Table('enum', key=('type', 'name'))[
         Column('type'),
         Column('name'),

File trac/htdocs/js/query.js

         td.className = "filter";
         if (property.type == "select") {
           focusElement = createSelect(propertyName, property.options, true);
-        } else if ((property.type == "text") || (property.type == "textarea")) {
+        } else if ((property.type == "text") || (property.type == "textarea")
+        		|| (property.type == "link")) {
           focusElement = createText(propertyName, 42);
         }
         td.appendChild(focusElement);

File trac/ticket/api.py

         Must return a list of `(field, message)` tuples, one for each problem
         detected. `field` can be `None` to indicate an overall problem with the
         ticket. Therefore, a return value of `[]` means everything is OK."""
+        
+class ITicketLinkController(Interface):
+    
+    def get_links():
+        """returns iterable of '(end1, end2)' tuples that make up a link. 
+        'end2' can be None for unidirectional links."""
 
-
+    def render_end(end):
+        """returns label"""
+        
 class TicketSystem(Component):
     implements(IPermissionRequestor, IWikiSyntaxProvider, IResourceManager)
 
         include_missing=False,
         doc="""Ordered list of workflow controllers to use for ticket actions
             (''since 0.11'').""")
+    link_controllers = ExtensionPoint(ITicketLinkController)
 
     restrict_owner = BoolOption('ticket', 'restrict_owner', 'false',
         """Make the owner field of tickets use a drop-down menu. See
     _fields = None
     _custom_fields = None
 
+    # regular expression to match links
+    NUMBERS_RE = re.compile(r'\d+', re.U)
+
     def __init__(self):
         self.log.debug('action controllers for ticket workflow: %r' % 
                 [c.__class__.__name__ for c in self.action_controllers])
         self._fields_lock = threading.RLock()
+        # initialize dictionary that maps from one end of a link to the other end
+        self.link_ends_map = {}
+        for controller in self.link_controllers:
+            for end1, end2 in controller.get_ends():
+                self.link_ends_map[end1] = end2
+                self.link_ends_map[end2] = end1
+        
 
     # Public API
 
                 continue
             field['custom'] = True
             fields.append(field)
+            
+        # fields for links
+        for controller in self.link_controllers:
+            for end1, end2 in controller.get_ends():
+                self._add_link_field(end1, controller, fields)
+                if end2 != None and end2 != end1:
+                    self._add_link_field(end2, controller, fields)
 
         return fields
-
+    
     reserved_field_names = ['report', 'order', 'desc', 'group', 'groupdesc',
                             'col', 'row', 'format', 'max', 'page', 'verbose']
 
+    def _add_link_field(self, end, controller, fields):
+        label = controller.render_end(end)
+        if end in [f['name'] for f in fields]:
+            self.log.warning('Duplicate field name "%s" (ignoring)',
+                             end)
+            return
+        field = {'name': end, 'type': 'link', 'label': label, 'link': True, 'format': 'wiki'}
+        fields.append(field)   
+
     def get_custom_fields(self):
         if self._custom_fields is None:
             self._fields_lock.acquire()
             return "%s (%s)" % (summary, status)
         else:
             return summary
+        
+    def parse_links(self, value):
+        if not value:
+            return []
+        return [int(id) for id in self.NUMBERS_RE.findall(value)]

File trac/ticket/links.py

+from trac.ticket.api import ITicketLinkController, ITicketManipulator,\
+    TicketSystem
+from trac.ticket.model import Ticket
+from copy import copy
+from trac.core import Component, implements
+
+class LinksProvider(Component):
+    """Link controller that provides links as specified in the [ticket-links]
+    section in the trac.ini configuration file.
+    """
+    
+    implements(ITicketLinkController, ITicketManipulator)
+    
+    def __init__(self):
+        self._links, self._labels, self._validators = self._get_links_config()
+
+    def get_ends(self):
+        return self._links
+    
+    def render_end(self, end):
+        return self._labels[end]
+        
+    def prepare_ticket(self, req, ticket, fields, actions):
+        pass
+        
+    def validate_ticket(self, req, ticket):
+        for end, validator in self._validators.items():
+            check = validator(ticket, end)
+            if check:
+                yield None, check
+        
+    def validate_no_cyle(self, ticket, end):
+        cycle = self.find_cycle(ticket, end, [])
+        if cycle != None:
+            cycle_str = ['#%s'%id for id in cycle]
+            return 'Cycle in ''%s'': %s' % (self.render_end(end), ' -> '.join(cycle_str))
+        return None
+
+    def _get_links_config(self):
+        links = []
+        labels = {}
+        validators = {}
+        config = self.config['ticket-links']
+        for name in [option for option, _ in config.options()
+                     if '.' not in option]:
+            ends = config.get(name).split(',')
+            if len(ends) < 1:
+                continue
+            end1 = ends[0]
+            end2 = None
+            if len(ends) > 1:
+                end2 = ends[1]
+            links.append((end1, end2))
+            label1 = config.get(end1 + '.label') or end1.capitalize()
+            labels[end1] = label1
+            if end2:
+                label2 = config.get(end2 + '.label') or end2.capitalize()
+                labels[end2] = label2
+            validator = config.get(name + '.validator')
+            if validator == 'no_cycle':
+                validators[end1] = self.validate_no_cyle
+                if end2:
+                    validators[end2] = self.validate_no_cyle
+
+        return links, labels, validators
+
+
+    def find_cycle(self, ticket, field, path):
+        if ticket.id in path:
+            path.append(ticket.id)
+            return path
+
+        path.append(ticket.id)
+
+        ticket_system = TicketSystem(self.env)
+        links = ticket_system.parse_links(ticket[field])
+        for link in links:
+            linked_ticket= Ticket(self.env, link)
+            cycle = self.find_cycle(linked_ticket, field, copy(path))
+            if  cycle != None:
+                return cycle
+        return None
+                

File trac/ticket/model.py

             db = self._get_db(db)
 
             # Fetch the standard ticket fields
-            std_fields = [f['name'] for f in self.fields if not f.get('custom')]
+            std_fields = [f['name'] for f in self.fields if not (f.get('custom') or f.get('link'))]
             cursor = db.cursor()
             cursor.execute("SELECT %s FROM ticket WHERE id=%%s"
                            % ','.join(std_fields), (tkt_id,))
         for name, value in cursor:
             if name in custom_fields and value is not None:
                 self.values[name] = value
+                
+        # Fetch links 
+        link_fields = [f['name'] for f in self.fields if f.get('link')]
+        for end in link_fields:
+            cursor.execute("SELECT destination FROM ticket_links WHERE source=%s and type=%s",
+                   (tkt_id, end))
+            link_list = []
+            for destination in cursor:
+                link_list.append(destination)
 
+            link_list.sort()
+            self.values[end] = ', '.join(['#%s' % v for v in link_list])
+            
     def __getitem__(self, name):
         return self.values.get(name)
 
         # Insert ticket record
         std_fields = []
         custom_fields = []
+        link_fields = []
         for f in self.fields:
             fname = f['name']
             if fname in self.values:
-                if f.get('custom'):
+                # ignore link fields
+                if f.get('link'):
+                    link_fields.append(fname)
+                elif f.get('custom'):
                     custom_fields.append(fname)
                 else:
                     std_fields.append(fname)
             cursor.executemany("INSERT INTO ticket_custom (ticket,name,value) "
                                "VALUES (%s,%s,%s)", [(tkt_id, name, self[name])
                                                      for name in custom_fields])
+        # Insert links
+        for end in link_fields:
+            ticket_system = TicketSystem(self.env)
+            dst_ids =  ticket_system.parse_links(self[end])
+            # TODO: check if target exists!
+            if len(dst_ids)>0:
+                cursor.executemany("INSERT INTO ticket_links (source,destination,type) "
+                                            "VALUES (%s,%s,%s)", [(tkt_id, int(dst_id), end)
+                                                                  for dst_id in dst_ids])
+                # insert other side for bidirectional links
+                other_end = ticket_system.link_ends_map[end]
+                if other_end:
+                    cursor.executemany("INSERT INTO ticket_links (source,destination,type) "
+                                    "VALUES (%s,%s,%s)", [(int(dst_id), tkt_id, other_end)
+                                                          for dst_id in dst_ids])
+                        
         if handle_ta:
             db.commit()
 
             self.values['cc'] = ', '.join(cclist)
 
         custom_fields = [f['name'] for f in self.fields if f.get('custom')]
+        link_fields = [f['name'] for f in self.fields if f.get('link')]
         for name in self._old.keys():
+            # ignore link fields here
+            if name in link_fields:
+                continue
             if name in custom_fields:
                 cursor.execute("SELECT * FROM ticket_custom " 
                                "WHERE ticket=%s and name=%s", (self.id, name))
         cursor.execute("UPDATE ticket SET changetime=%s WHERE id=%s",
                        (when_ts, self.id))
 
+        # update links
+        for end in link_fields:
+            if end in self._old:
+                new_ids = TicketSystem(self.env).parse_links(self[end])
+                old_ids = TicketSystem(self.env).parse_links(self._old[end])
+                list_changed = False
+                for id in new_ids + old_ids:
+                    if id in new_ids and id not in old_ids:
+                        # New link added
+                        cursor.execute("INSERT INTO ticket_links (source,destination,type) "
+                                            "VALUES (%s,%s,%s)", [self.id, id, end])
+                        other_end = TicketSystem(self.env).link_ends_map[end]
+                        list_changed = True
+                        if other_end:
+                            cursor.execute("INSERT INTO ticket_links (source,destination,type) "
+                                                "VALUES (%s,%s,%s)", [id, self.id, other_end])
+                    elif id not in new_ids and id in old_ids:
+                        # Old link removed
+                        cursor.execute("DELETE FROM ticket_links WHERE source=%s AND destination=%s "
+                                            "AND type=%s", [self.id, id, end])
+                        other_end = TicketSystem(self.env).link_ends_map[end]
+                        list_changed = True
+                        if other_end:
+                            cursor.execute("DELETE FROM ticket_links WHERE source=%s AND destination=%s "
+                                                "AND type=%s", [id, self.id, other_end])
+                if list_changed:
+                    cursor.execute("INSERT INTO ticket_change "
+                                   "(ticket,time,author,field,oldvalue,newvalue) "
+                                   "VALUES (%s, %s, %s, %s, %s, %s)",
+                                   (self.id, when_ts, author, end, self._old[end],
+                                    self[end]))               
+
         if handle_ta:
             db.commit()
         old_values = self._old
         self._old = {}
         self.values['changetime'] = when
-
+        
         for listener in TicketSystem(self.env).change_listeners:
             listener.ticket_changed(self, comment, author, old_values)
         return True
-
+    
     def get_changelog(self, when=None, db=None):
         """Return the changelog as a list of tuples of the form
         (time, author, field, oldvalue, newvalue, permanent).
         cursor.execute("DELETE FROM ticket WHERE id=%s", (self.id,))
         cursor.execute("DELETE FROM ticket_change WHERE ticket=%s", (self.id,))
         cursor.execute("DELETE FROM ticket_custom WHERE ticket=%s", (self.id,))
+        cursor.execute("DELETE FROM ticket_links WHERE source=%s OR destination=%s", 
+                       (self.id,self.id))
 
         if handle_ta:
             db.commit()

File trac/ticket/query.py

         cols.extend([c for c in self.constraints.keys() if not c in cols])
 
         custom_fields = [f['name'] for f in self.fields if 'custom' in f]
+        link_fields = [f['name'] for f in self.fields if 'link' in f]
 
         sql = []
-        sql.append("SELECT " + ",".join(['t.%s AS %s' % (c, c) for c in cols
-                                         if c not in custom_fields]))
+        sql.append("SELECT DISTINCT " + ",".join(['t.%s AS %s' % (c, c) for c in cols
+                                         if c not in (custom_fields + link_fields)]))
         sql.append(",priority.value AS priority_value")
         for k in [k for k in cols if k in custom_fields]:
             sql.append(",%s.value AS %s" % (k, k))
            sql.append("\n  LEFT OUTER JOIN ticket_custom AS %s ON " \
                       "(id=%s.ticket AND %s.name='%s')" % (k, k, k, k))
 
+        # Join with ticket_links table as necessary
+        for k in [k for k in cols if k in link_fields]:
+            sql.append("\n  LEFT OUTER JOIN ticket_links AS %s ON " \
+                      "(id=%s.source AND %s.type='%s')" % (k, k, k, k))
+
         # Join with the enum table for proper sorting
         for col in [c for c in enum_columns
                     if c == self.order or c == self.group or c == 'priority']:
                        % (col, col, col))
 
         def get_constraint_sql(name, value, mode, neg):
-            if name not in custom_fields:
+            if name in custom_fields:
+                col = name + '.value'
+            elif name in link_fields:
+                col = name + '.destination'
+                # for now, search for any value and ignore all modes
+                dst_ids = TicketSystem(self.env).parse_links(value)
+                sql_snippets = " OR ".join([("%s%s=CAST(%%s AS int)" % (col, neg and '!' or '')) 
+                                for _ in dst_ids])
+                return sql_snippets, [dst_id for dst_id in dst_ids] 
+            else:
                 col = 't.' + name
-            else:
-                col = name + '.value'
             value = value[len(mode) + neg:]
 
             if name in self.time_fields:
         cols = self.get_columns()
         labels = dict([(f['name'], f['label']) for f in self.fields])
         wikify = set(f['name'] for f in self.fields 
-                     if f['type'] == 'text' and f.get('format') == 'wiki')
+                     if f['type'] in ('text', 'link') and f.get('format') == 'wiki')
 
         headers = [{
             'name': col, 'label': labels.get(col, _('Ticket')),
             {'name': _("is"), 'value': ""},
             {'name': _("is not"), 'value': "!"}
         ]
+        modes['link'] = [
+            {'name': _("contains"), 'value': ""}
+        ]
 
         groups = {}
         groupsequence = []
         max = args.get('max')
         if max is None and format in ('csv', 'tab'):
             max = 0 # unlimited unless specified explicitly
+            
         query = Query(self.env, req.args.get('report'),
                       constraints, cols, args.get('order'),
                       'desc' in args, args.get('group'),
             for var in ('query_constraints', 'query_time', 'query_tickets'):
                 if var in req.session:
                     del req.session[var]
+            print query.get_href(req.href)
             req.redirect(query.get_href(req.href))
 
         # Add registered converters
         # Don't allow the user to remove the id column        
         data['all_columns'].remove('id')
         data['all_textareas'] = query.get_all_textareas()
+        data['ticket_indent'] = range(100)
 
         add_stylesheet(req, 'common/css/report.css')
         add_script(req, 'common/js/query.js')

File trac/ticket/templates/query.html

             <py:for each="field_name in field_names" py:with="field = fields[field_name]">
               <py:for each="constraint_name, constraint in constraints.items()">
                 <tbody py:if="field_name == constraint_name"
-                  py:with="multiline = field.type in ('select', 'text', 'textarea', 'time')">
+                  py:with="multiline = field.type in ('select', 'text', 'textarea', 'time', 'link')">
                   <py:for each="constraint_idx, constraint_value in enumerate(constraint['values'])">
                     <tr class="${field_name}" py:if="multiline or constraint_idx == 0">
                       <py:choose test="constraint_idx">
                           <label for="${field_name}_off" class="control">no</label>
                         </py:when>
 
-                        <py:when test="field.type in ('text', 'textarea')">
+                        <py:when test="field.type in ('text', 'textarea', 'link')">
                           <input type="text" name="${field_name}" value="$constraint_value" size="42" />
                         </py:when>
                         

File trac/ticket/tests/links.py

+from trac.ticket.model import Ticket
+from trac.test import EnvironmentStub, Mock
+from trac.ticket.links import LinksProvider
+from trac.ticket.api import TicketSystem
+from trac.ticket.query import Query
+from trac.util.datefmt import utc
+import unittest
+
+class TicketTestCase(unittest.TestCase):
+    def setUp(self):
+        self.env = EnvironmentStub(default_data=True)
+        self.env.config.set('ticket-links', 'dependency', 'dependson,dependent')
+        self.env.config.set('ticket-links', 'dependency.validator', 'no_cycle')
+        self.req = Mock(href=self.env.href, authname='anonymous', tz=utc)
+
+    def _insert_ticket(self, summary, **kw):
+        """Helper for inserting a ticket into the database"""
+        ticket = Ticket(self.env)
+        for k,v in kw.items():
+            ticket[k] = v
+        return ticket.insert()
+
+    def _create_a_ticket(self):
+        ticket = Ticket(self.env)
+        ticket['reporter'] = 'santa'
+        ticket['summary'] = 'Foo'
+        ticket['foo'] = 'This is a custom field'
+        return ticket
+
+    # TicketSystem tests
+    
+    def test_get_ticket_fields(self):
+        ticket_system = TicketSystem(self.env)
+        fields = ticket_system.get_ticket_fields()
+        link_fields = [f['name'] for f in fields if f.get('link')]
+        self.assertEquals(2, len(link_fields))
+
+    def test_update_links(self):
+        ticket = self._create_a_ticket()
+        ticket.insert()
+        ticket = self._create_a_ticket()
+        ticket['dependson'] = '#1, #2'
+        ticket.insert()
+
+        # Check if ticket link in #1 has been updated
+        ticket = Ticket(self.env, 1)
+        self.assertEqual(1, ticket.id)
+        self.assertEqual('#2', ticket['dependent'])
+        
+        # Remove link from #2 to #1
+        ticket = Ticket(self.env, 2)
+        ticket['dependson'] = '#2'
+        ticket.save_changes("me", "testing")
+
+        # Check if ticket link in #1 has been updated
+        ticket = Ticket(self.env, 1)
+        self.assertEqual(1, ticket.id)
+        self.assertEqual('', ticket['dependent'])
+        
+    def test_save_retrieve_links(self):
+        ticket = self._create_a_ticket()
+        ticket.insert()
+        ticket = self._create_a_ticket()
+        ticket['dependson'] = '#1, #2'
+        ticket.insert()
+
+        # Check if ticket link in #1 has been updated
+        ticket = Ticket(self.env, 1)
+        self.assertEqual(1, ticket.id)
+        self.assertEqual('#2', ticket['dependent'])
+
+    def test_query_by_result(self):
+        ticket = self._create_a_ticket()
+        ticket.insert()
+        ticket = self._create_a_ticket()
+        ticket['dependson'] = '#1'
+        ticket.insert()
+        query = Query.from_string(self.env, 'dependson=1', order='id')
+        sql, args = query.get_sql()
+        tickets = query.execute(self.req)
+        self.assertEqual(len(tickets), 1)
+        self.assertEqual(tickets[0]['id'], 2)
+    
+    def test_query_by_result2(self):
+        ticket = self._create_a_ticket()
+        ticket.insert()
+        ticket = self._create_a_ticket()
+        ticket['dependson'] = '#1'
+        ticket.insert()
+        query = Query.from_string(self.env, 'dependent=2', order='id')
+        sql, args = query.get_sql()
+        tickets = query.execute(self.req)
+        self.assertEqual(len(tickets), 1)
+        self.assertEqual(tickets[0]['id'], 1)
+    
+
+    def test_validator_no_cyle(self):
+        ticket1 = self._create_a_ticket()
+        ticket1.insert()
+        ticket2 = self._create_a_ticket()
+        ticket2['dependson'] = '#1'
+        links_provider = LinksProvider(self.env)
+        issues = links_provider.validate_ticket(self.req, ticket2)
+        self.assertEquals(sum(1 for _ in issues), 0)
+        ticket2.insert()
+        ticket1['dependson'] = '#2'
+        issues = links_provider.validate_ticket(self.req, ticket1)
+        self.assertEquals(sum(1 for _ in issues), 1)
+        

File trac/ticket/web_ui.py

             type_ = field['type']
  
             # enable a link to custom query for all choice fields
-            if type_ not in ['text', 'textarea']:
+            if type_ not in ['text', 'textarea', 'link']:
                 field['rendered'] = self._query_link(req, name, ticket[name])
 
             # per field settings
                 if value in ('1', '0'):
                     field['rendered'] = self._query_link(req, name, value,
                                 value == '1' and _('yes') or _('no'))
-            elif type_ == 'text':
+            elif type_ in ('text', 'link'):
                 if field.get('format') == 'wiki':
                     field['rendered'] = format_to_oneliner(self.env, context,
                                                            ticket[name])

File trac/upgrades/db22.py

+from trac.db import Table, Column, Index, DatabaseManager
+
+def do_upgrade(env, ver, cursor):
+    """Add new table for links
+    """
+    table = Table('ticket_links', key=('source', 'destination', 'type'))[
+        Column('source', type='int'),
+        Column('destination', type='int'),
+        Column('type')]
+    db_connector, _ = DatabaseManager(env)._get_connector()
+    for stmt in db_connector.to_sql(table):
+        cursor.execute(stmt)