Index: setup.py
===================================================================
--- setup.py	(revision 7937)
+++ setup.py	(working copy)
@@ -107,6 +107,7 @@
         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
Index: trac/db_default.py
===================================================================
--- trac/db_default.py	(revision 7937)
+++ trac/db_default.py	(working copy)
@@ -17,7 +17,7 @@
 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
@@ -131,6 +131,10 @@
         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'),
Index: trac/upgrades/db22.py
===================================================================
--- trac/upgrades/db22.py	(revision 0)
+++ trac/upgrades/db22.py	(revision 0)
@@ -0,0 +1,12 @@
+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)
Index: trac/htdocs/js/query.js
===================================================================
--- trac/htdocs/js/query.js	(revision 7937)
+++ trac/htdocs/js/query.js	(working copy)
@@ -244,7 +244,8 @@
         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);
Index: trac/ticket/web_ui.py
===================================================================
--- trac/ticket/web_ui.py	(revision 7937)
+++ trac/ticket/web_ui.py	(working copy)
@@ -1121,7 +1121,7 @@
             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
@@ -1188,7 +1188,7 @@
                 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])
Index: trac/ticket/links.py
===================================================================
--- trac/ticket/links.py	(revision 0)
+++ trac/ticket/links.py	(revision 0)
@@ -0,0 +1,83 @@
+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
+                
\ No newline at end of file
Index: trac/ticket/query.py
===================================================================
--- trac/ticket/query.py	(revision 7937)
+++ trac/ticket/query.py	(working copy)
@@ -393,10 +393,11 @@
         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))
@@ -407,6 +408,11 @@
            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']:
@@ -421,10 +427,17 @@
                        % (col, col, col))
 
         def get_constraint_sql(name, value, mode, neg):
-            if name not in custom_fields:
-                col = 't.' + name
-            else:
+            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
             value = value[len(mode) + neg:]
 
             if name in self.time_fields:
@@ -598,7 +611,7 @@
         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')),
@@ -634,6 +647,9 @@
             {'name': _("is"), 'value': ""},
             {'name': _("is not"), 'value': "!"}
         ]
+        modes['link'] = [
+            {'name': _("contains"), 'value': ""}
+        ]
 
         groups = {}
         groupsequence = []
@@ -819,6 +835,7 @@
         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'),
@@ -832,6 +849,7 @@
             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
@@ -969,6 +987,7 @@
         # 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')
Index: trac/ticket/templates/query.html
===================================================================
--- trac/ticket/templates/query.html	(revision 7937)
+++ trac/ticket/templates/query.html	(working copy)
@@ -43,7 +43,7 @@
             <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">
@@ -93,7 +93,7 @@
                           <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>
                         
Index: trac/ticket/model.py
===================================================================
--- trac/ticket/model.py	(revision 7937)
+++ trac/ticket/model.py	(working copy)
@@ -97,7 +97,7 @@
             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,))
@@ -123,7 +123,19 @@
         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)
 
@@ -197,10 +209,14 @@
         # 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)
@@ -215,6 +231,22 @@
             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()
 
@@ -271,7 +303,11 @@
             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))
@@ -300,16 +336,48 @@
         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).
@@ -357,6 +425,8 @@
         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()
Index: trac/ticket/api.py
===================================================================
--- trac/ticket/api.py	(revision 7937)
+++ trac/ticket/api.py	(working copy)
@@ -140,8 +140,16 @@
         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)
 
@@ -151,6 +159,7 @@
         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
@@ -160,10 +169,20 @@
     _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
 
@@ -283,12 +302,28 @@
                 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()
@@ -458,3 +493,8 @@
             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)]
Index: trac/ticket/tests/links.py
===================================================================
--- trac/ticket/tests/links.py	(revision 0)
+++ trac/ticket/tests/links.py	(revision 0)
@@ -0,0 +1,109 @@
+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)
+        
\ No newline at end of file

