Index: htdocs/css/timeline.css
===================================================================
--- htdocs/css/timeline.css	(revision 1768)
+++ htdocs/css/timeline.css	(working copy)
@@ -40,6 +40,8 @@
 /* Apply icon background-image twice to avoid hover-flicker in IE/Win */
 dt.changeset, dt.changeset a { background-image: url(../changeset.png) !important }
 dt.newticket, dt.newticket a { background-image: url(../newticket.png) !important }
+dt.resolvedticket, dt.resolvedticket a { background-image: url(../resolvedticket.png) !important }
+dt.reopenedticket, dt.reopenedticket a { background-image: url(../reopenedticket.png) !important }
 dt.closedticket, dt.closedticket a { background-image: url(../closedticket.png) !important }
 dt.wiki, dt.wiki a { background-image: url(../wiki.png) !important }
 dt.milestone, dt.milestone a { background-image: url(../milestone.png) !important }
Index: wiki-default/TracIni
===================================================================
--- wiki-default/TracIni	(revision 1768)
+++ wiki-default/TracIni	(working copy)
@@ -27,6 +27,7 @@
 See also: TracLogging
 
 == [ticket] ==
+|| workflow || Ticket workflow class.  If not specified, it is ''trac.workflows.SimpleWorkflow'' ||
 || default_version   || Default version for newly created tickets ||
 || default_severity  || Default severity for newly created tickets ||
 || default_priority  || Default priority for newly created tickets ||
Index: wiki-default/TracAdmin
===================================================================
--- wiki-default/TracAdmin	(revision 1768)
+++ wiki-default/TracAdmin	(working copy)
@@ -28,10 +28,10 @@
 permission add <user> <action> [action] [...]     -- Add a new permission rule                             
 permission remove <user> <action> [action] [...]  -- Remove permission rule                                
 component list                                    -- Show available components                             
-component add <name> <owner>                      -- Add a new component                                   
+component add <name> <owner> [<qaowner>]          -- Add a new component                                   
 component rename <name> <newname>                 -- Rename a component                                    
 component remove <name>                           -- Remove/uninstall component                            
-component chown <name> <owner>                    -- Change component ownership                            
+component chown <name> <owner> [<qaowner>]        -- Change component ownership                            
 priority list                                     -- Show possible ticket priorities                       
 priority add <value>                              -- Add a priority value option                           
 priority change <value> <newvalue>                -- Change a priority value                               
@@ -46,10 +46,11 @@
 version time <name> <time>                        -- Set version date (Format: "Jun 3, 2003")              
 version remove <name>                             -- Remove version                                        
 milestone list                                    -- Show milestones                                       
-milestone add <name> [time]                       -- Add milestone                                         
+milestone add <name> [<owner> [time]]             -- Add milestone                                         
 milestone rename <name> <newname>                 -- Rename milestone                                      
 milestone time <name> <time>                      -- Set milestone date (Format: "Jun 3, 2003")            
 milestone remove <name>                           -- Remove milestone                                      
+milestone chown <name> <owner>                    -- Change milestone ownership
 }}}
 
 == Interactive Mode ==
Index: scripts/trac-admin
===================================================================
--- scripts/trac-admin	(revision 1768)
+++ scripts/trac-admin	(working copy)
@@ -292,10 +292,10 @@
 
 #    ## Component
     _help_component = [('component list', 'Show available components'),
-                       ('component add <name> <owner>', 'Add a new component'),
+                       ('component add <name> <owner> [<qaowner>]', 'Add a new component'),
                        ('component rename <name> <newname>', 'Rename a component'),
                        ('component remove <name>', 'Remove/uninstall component'),
-                       ('component chown <name> <owner>', 'Change component ownership')]
+                       ('component chown <name> <owner> [<qaowner>]', 'Change component ownership')]
 
     def complete_component (self, text, line, begidx, endidx):
         if begidx in [16,17]:
@@ -311,10 +311,14 @@
         try:
             if arg[0]  == 'list':
                 self._do_component_list()
-            elif arg[0] == 'add' and len(arg)==3:
+            elif arg[0] == 'add' and len(arg) in [3,4]:
                 name = arg[1]
                 owner = arg[2]
-                self._do_component_add(name, owner)
+                if len(arg) == 4:
+                    qaowner = arg[3]
+                else:
+                    qaowner = owner
+                self._do_component_add(name, owner, qaowner)
             elif arg[0] == 'rename' and len(arg)==3:
                 name = arg[1]
                 newname = arg[2]
@@ -322,22 +326,26 @@
             elif arg[0] == 'remove'  and len(arg)==2:
                 name = arg[1]
                 self._do_component_remove(name)
-            elif arg[0] == 'chown' and len(arg)==3:
+            elif arg[0] == 'chown' and len(arg) in [3,4]:
                 name = arg[1]
                 owner = arg[2]
-                self._do_component_set_owner(name, owner)
+                if len(arg) == 4:
+                    qaowner = arg[3]
+                else:
+                    qaowner = owner
+                self._do_component_set_owner(name, owner, qaowner)
             else:    
                 self.do_help ('component')
         except Exception, e:
             print 'Component %s failed:' % arg[0], e
 
     def _do_component_list(self):
-        data = self.db_execsql('SELECT name, owner FROM component') 
-        self.print_listing(['Name', 'Owner'], data)
+        data = self.db_execsql('SELECT name, owner, qaowner FROM component') 
+        self.print_listing(['Name', 'Owner', 'QA Owner'], data)
 
-    def _do_component_add(self, name, owner):
-        data = self.db_execsql("INSERT INTO component VALUES('%s', '%s')"
-                               % (name, owner))
+    def _do_component_add(self, name, owner, qaowner):
+        data = self.db_execsql("INSERT INTO component VALUES('%s', '%s', '%s')"
+                               % (name,owner,qaowner))
 
     def _do_component_rename(self, name, newname):
         cnx = self.db_open()
@@ -362,15 +370,15 @@
         data = self.db_execsql("DELETE FROM component WHERE name='%s'"
                                % (name))
 
-    def _do_component_set_owner(self, name, owner):
+    def _do_component_set_owner(self, name, owner, qaowner):
         cnx = self.db_open()
         cursor = cnx.cursor ()
         cursor.execute('SELECT name FROM component WHERE name=%s', name)
         data = cursor.fetchone()
         if not data:
             raise Exception("No such component '%s'" % name)
-        data = self.db_execsql("UPDATE component SET owner='%s' WHERE name='%s'"
-                               % (owner,name))
+        data = self.db_execsql("UPDATE component SET owner='%s', qaowner='%s' WHERE name='%s'"
+                               % (owner,qaowner,name))
 
 
     ## Permission
@@ -797,9 +805,10 @@
 
     ## Milestone
     _help_milestone = [('milestone list', 'Show milestones'),
-                       ('milestone add <name> [time]', 'Add milestone'),
+                       ('milestone add <name> [<owner> [time]]', 'Add milestone'),
                        ('milestone rename <name> <newname>',
                         'Rename milestone'),
+                       ('milestone chown <name> <newowner>', 'Change milestone owner'),
                        ('milestone time <name> <time>', 'Set milestone date (Format: "Jun 3, 2003")'),
                        ('milestone remove <name>', 'Remove milestone')]
 
@@ -807,14 +816,54 @@
 
         if begidx in [15,17]:
             comp = self.get_milestone_list ()
+        elif begidx > 15 and line.startswith('milestone chown '):
+            comp = self.get_user_list()
         elif begidx < 15:
-            comp = ['list','add','rename','time','remove']
+            comp = ['list','add','rename','chown','time','remove']
         return self.word_complete(text, comp)
 
     def do_milestone(self, line):
-        self._do_mile_ver('milestone', line)
+        type = 'milestone'
+        arg = self.arg_tokenize(line)
+        try:
+            if arg[0]  == 'list':
+                self._do_milestone_list()
+            elif arg[0] == 'add' and len(arg) in [2,3,4]:
+                name = arg[1]
+                self._do_mile_ver_add(type, name)
+                if len(arg) >= 3:
+                    owner = arg[2]
+                    self._do_mile_ver_chown(type, name, owner)
+                if len(arg) >= 4:
+                    time = arg[3]
+                    self._do_mile_ver_time(type, name, time)
+            elif arg[0] == 'rename' and len(arg)==3:
+                name = arg[1]
+                newname = arg[2]
+                self._do_mile_ver_rename(type, name, newname)
+            elif arg[0] == 'chown' and len(arg)==3:
+                name = arg[1]
+                owner = arg[2]
+                self._do_mile_ver_chown(type, name, owner)
+            elif arg[0] == 'time' and len(arg)==3:
+                name = arg[1]
+                time = arg[2]
+                self._do_mile_ver_time(type, name, time)
+            elif arg[0] == 'remove' and len(arg)==2:
+                name = arg[1]
+                self._do_mile_ver_remove(type, name)
+            else:
+                self.do_help (type)
+        except Exception, e:
+            print 'Command %s failed:' % arg[0], e
 
+    def _do_milestone_list(self):
+        data = self.db_execsql("SELECT name,owner,time FROM milestone ORDER BY time,name")
+        data = map(lambda x: (x[0], x[1], x[2] and time.strftime('%c', time.localtime(x[2]))), data)
+        #print data
+        self.print_listing(['Name', 'Owner', 'Time'], data)
 
+
     ## Version
     _help_version = [('version list', 'Show versions'),
                        ('version add <name> [time]', 'Add version'),
@@ -834,7 +883,7 @@
     def do_version(self, line):
         self._do_mile_ver('version', line)
 
-    # Milestone and Version are identical,  methods
+    # Milestone and Version are almost identical,  methods
 
     def _do_mile_ver(self, type, line):
         arg = self.arg_tokenize(line)
@@ -925,6 +974,10 @@
         else:
             print >> sys.stderr, 'Unknown time format'
 
+    def _do_mile_ver_chown(self, type, name, owner):
+        data = self.db_execsql("UPDATE %s SET owner='%s' WHERE name='%s'"
+                               % (type, owner, name));
+
     _help_upgrade = [('upgrade', 'Upgrade database to current version.')]
     def do_upgrade(self, line):
         arg = self.arg_tokenize(line)
Index: setup.py
===================================================================
--- setup.py	(revision 1768)
+++ setup.py	(working copy)
@@ -206,7 +206,8 @@
       author_email="info@edgewall.com",
       license=LICENSE,
       url=URL,
-      packages=['trac', 'trac.upgrades', 'trac.wikimacros', 'trac.mimeviewers'],
+      packages=['trac', 'trac.upgrades', 'trac.wikimacros', 'trac.mimeviewers',
+                'trac.workflows'],
       data_files=[(_p('share/trac/templates'), glob('templates/*')),
                   (_p('share/trac/htdocs'), glob(_p('htdocs/*.*')) + [_p('htdocs/README')]),
                   (_p('share/trac/htdocs/css'), glob(_p('htdocs/css/*'))),
Index: trac/db_default.py
===================================================================
--- trac/db_default.py	(revision 1768)
+++ trac/db_default.py	(working copy)
@@ -21,7 +21,7 @@
 
 
 # Database version identifier. Used for automatic upgrades.
-db_version = 7
+db_version = 8
 
 def __mkreports(reps):
     """Utility function used to create report data in same syntax as the
@@ -125,12 +125,14 @@
 );
 CREATE TABLE component (
          name            text PRIMARY KEY,
-         owner           text
+         owner           text,
+         qaowner         text
 );
 CREATE TABLE milestone (
          id              integer PRIMARY KEY,
          name            text,
          time            integer,
+         owner           text,
          descr           text,
          UNIQUE(name)
 );
@@ -209,8 +211,8 @@
 """,
 """
 SELECT p.value AS __color__,
-   version AS __group__,
-   id AS ticket, summary, component, version, severity, 
+   (CASE WHEN IFNULL(version, '') = '' THEN 'Not Specified' ELSE 'Version ' || version END) AS __group__,
+   id AS ticket, summary, component, milestone, severity, 
    (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
    time AS created,
    changetime AS _changetime, description AS _description,
@@ -218,10 +220,10 @@
   FROM ticket t, enum p
   WHERE status IN ('new', 'assigned', 'reopened') 
 AND p.name = t.priority AND p.type = 'priority'
-  ORDER BY (version IS NULL),version, p.value, severity, time
+  ORDER BY (IFNULL(version, '') = '') DESC,version, p.value, severity, time
 """),
 #----------------------------------------------------------------------------
-('All Tickets by Milestone',
+('Active Tickets by Milestone',
 """
 This report shows how to color results by priority,
 while grouping results by milestone.
@@ -231,7 +233,7 @@
 """,
 """
 SELECT p.value AS __color__,
-   milestone||' Release' AS __group__,
+   (CASE WHEN IFNULL(milestone, '') = '' THEN 'Not Assigned' ELSE milestone||' Release' END) AS __group__,
    id AS ticket, summary, component, version, severity, 
    (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
    time AS created,
@@ -240,7 +242,7 @@
   FROM ticket t, enum p
   WHERE status IN ('new', 'assigned', 'reopened') 
 AND p.name = t.priority AND p.type = 'priority'
-  ORDER BY (milestone IS NULL),milestone, p.value, severity, time
+  ORDER BY (IFNULL(milestone, '') = '') DESC,milestone, p.value, severity, time
 """),
 #----------------------------------------------------------------------------
 ('Assigned, Active Tickets by Owner',
@@ -248,16 +250,15 @@
 List assigned tickets, group by ticket owner, sorted by priority.
 """,
 """
-
 SELECT p.value AS __color__,
-   owner AS __group__,
-   id AS ticket, summary, component, milestone, severity, time AS created,
+   (CASE WHEN IFNULL(owner, '') = '' THEN 'Not Assigned' ELSE owner END) AS __group__,
+   id AS ticket, summary, component, version, milestone, severity, time AS created,
    changetime AS _changetime, description AS _description,
    reporter AS _reporter
   FROM ticket t,enum p
   WHERE status = 'assigned'
 AND p.name=t.priority AND p.type='priority'
-  ORDER BY owner, p.value, severity, time
+  ORDER BY (IFNULL(owner, '') = '') DESC, owner, p.value, severity, time
 """),
 #----------------------------------------------------------------------------
 ('Assigned, Active Tickets by Owner (Full Description)',
@@ -268,7 +269,7 @@
 """
 SELECT p.value AS __color__,
    owner AS __group__,
-   id AS ticket, summary, component, milestone, severity, time AS created,
+   id AS ticket, summary, component, version, milestone, severity, time AS created,
    description AS _description_,
    changetime AS _changetime, reporter AS _reporter
   FROM ticket t, enum p
@@ -283,19 +284,20 @@
 """,
 """
 SELECT p.value AS __color__,
-   t.milestone AS __group__,
+   (CASE WHEN IFNULL(t.milestone, '') = '' THEN 'Not Assigned' ELSE t.milestone || ' Release' END) AS __group__,
    (CASE status 
       WHEN 'closed' THEN 'color: #777; background: #ddd; border-color: #ccc;'
       ELSE 
         (CASE owner WHEN '$USER' THEN 'font-weight: bold' END)
     END) AS __style__,
    id AS ticket, summary, component, status, 
-   resolution,version, severity, priority, owner,
+   (CASE WHEN resolution ISNULL THEN '' ELSE resolution END) AS resolution,
+   version, severity, priority, owner,
    changetime AS modified,
    time AS _time,reporter AS _reporter
   FROM ticket t,enum p
   WHERE p.name=t.priority AND p.type='priority'
-  ORDER BY (milestone IS NULL), milestone DESC, (status = 'closed'), 
+  ORDER BY (IFNULL(milestone, '') = '') DESC, milestone DESC, (status = 'closed'), 
         (CASE status WHEN 'closed' THEN modified ELSE -p.value END) DESC
 """),
 #----------------------------------------------------------------------------
@@ -307,15 +309,18 @@
 """,
 """
 SELECT p.value AS __color__,
-   (CASE status WHEN 'assigned' THEN 'Assigned' ELSE 'Owned' END) AS __group__,
-   id AS ticket, summary, component, version, milestone,
-   severity, priority, time AS created,
+   (CASE WHEN status <> 'new' THEN 'Assigned'
+         ELSE 'Owned in ' || IFNULL(milestone, 'N/A')
+    END) AS __group__,
+   id AS ticket, summary, component, status, resolution, version, milestone,
+   priority, time AS created,
    changetime AS _changetime, description AS _description,
    reporter AS _reporter
   FROM ticket t, enum p
-  WHERE t.status IN ('new', 'assigned', 'reopened') 
+  WHERE t.status <> 'closed' 
 AND p.name = t.priority AND p.type = 'priority' AND owner = '$USER'
-  ORDER BY (status = 'assigned') DESC, p.value, milestone, severity, time
+  ORDER BY (status = 'new'), (IFNULL(milestone, '') = '') DESC,
+           milestone, p.value, resolution, time
 """),
 #----------------------------------------------------------------------------
 ('Active Tickets, Mine first',
@@ -338,6 +343,173 @@
   WHERE status IN ('new', 'assigned', 'reopened') 
 AND p.name = t.priority AND p.type = 'priority'
   ORDER BY (owner = '$USER') DESC, p.value, milestone, severity, time
+"""),
+#----------------------------------------------------------------------------
+('Open Tickets, Mine first',
+"""
+ * List all not closed tickets by priority.
+ * Show all tickets owned by the logged in user in a group first.
+""",
+"""
+SELECT p.value AS __color__,
+   (CASE owner 
+     WHEN '$USER' THEN 'My Tickets' 
+     ELSE 'Open Tickets' 
+    END) AS __group__,
+   id AS ticket, summary, component, status, version, milestone, severity, 
+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status <> 'closed' 
+AND p.name = t.priority AND p.type = 'priority'
+  ORDER BY (owner = '$USER') DESC, p.value, milestone, severity, time
+"""),
+#----------------------------------------------------------------------------
+('Open Tickets by Version',
+"""
+ * List all not closed tickets by priority.
+ * Group results by version.
+""",
+"""
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(version, '') = '' THEN 'Not Specified' ELSE 'Version ' || version END) AS __group__,
+   id AS ticket, summary, component, status, milestone, severity, 
+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status <> 'closed'
+AND p.name = t.priority AND p.type = 'priority'
+  ORDER BY (IFNULL(version, '') = '') desc,version, p.value, severity, time
+"""),
+#----------------------------------------------------------------------------
+('Open Tickets by Milestone',
+"""
+ * List all not closed tickets by priority.
+ * Group results by milestone.
+""",
+"""
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(milestone, '') = '' THEN 'Not Assigned' ELSE milestone||' Release' END) AS __group__,
+   id AS ticket, summary, component, status, version, severity, 
+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status <> 'closed' 
+AND p.name = t.priority AND p.type = 'priority'
+  ORDER BY (IFNULL(milestone, '') = '') DESC,milestone, p.value, severity, time
+"""),
+#----------------------------------------------------------------------------
+('Open Tickets by Owner',
+"""
+List not closed tickets, group by ticket owner, sorted by priority.
+""",
+"""
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(owner, '') = '' THEN 'Not Assigned' ELSE owner END) AS __group__,
+   id AS ticket, summary, component, status, version, milestone, severity,
+   time AS created, changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t,enum p
+  WHERE status <> 'closed'
+AND p.name=t.priority AND p.type='priority'
+  ORDER BY (IFNULL(owner, '') = '') DESC, owner, p.value, severity, time
+"""),
+#----------------------------------------------------------------------------
+('Open Tickets by Status',
+"""
+ * List all not closed tickets by priority.
+ * Group results by status.
+""",
+"""
+SELECT p.value AS __color__,
+   status AS __group__,
+   id AS ticket, summary, component, version, milestone, severity, owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum q, enum p
+  WHERE status <> 'closed' 
+AND q.name = t.status AND q.type = 'status'
+AND p.name = t.priority AND p.type = 'priority'
+  ORDER BY q.value, p.value, severity, time
+"""),
+#----------------------------------------------------------------------------
+('Resolved Tickets, Mine first',
+"""
+ * List all resolved tickets by priority.
+ * Show all tickets owned by the logged in user in a group first.
+""",
+"""
+SELECT p.value AS __color__,
+   (CASE owner 
+     WHEN '$USER' THEN 'My Tickets' 
+     ELSE 'Active Tickets' 
+    END) AS __group__,
+   id AS ticket, summary, component, version, milestone, severity, 
+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status = 'resolved' 
+AND p.name = t.priority AND p.type = 'priority'
+  ORDER BY (owner = '$USER') DESC, p.value, milestone, severity, time
+"""),
+#----------------------------------------------------------------------------
+('Resolved Tickets by Milestone',
+"""
+List resolved tickets, sorted by priority, grouped by milestone
+""",
+"""
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(milestone, '') = '' THEN 'Not Assigned' ELSE milestone||' Release' END) AS __group__,
+   id AS ticket, summary, component, version, severity, 
+   (CASE status WHEN 'assigned' THEN owner||' *' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status = 'resolved' 
+AND p.name = t.priority AND p.type = 'priority'
+  ORDER BY (IFNULL(milestone, '') = '') DESC,milestone, p.value, severity, time
+"""),
+#----------------------------------------------------------------------------
+('Resolved Tickets by Owner',
+"""
+List resolved tickets, group by ticket owner, sorted by priority.
+""",
+"""
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(owner, '') = '' THEN 'Not Assigned' ELSE owner END) AS __group__,
+   id AS ticket, summary, component, version, milestone, severity, time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t,enum p
+  WHERE status = 'resolved'
+AND p.name=t.priority AND p.type='priority'
+  ORDER BY (IFNULL(owner, '') = '') DESC, owner, p.value, severity, time
+"""),
+#----------------------------------------------------------------------------
+('Completed Tickets by Milestone (Full Description)',
+"""
+Release Notes: List verified and closed tickets, group by milestone, include description.
+""",
+"""
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(milestone, '') = '' THEN 'Not Assigned' ELSE milestone||' Release' END) AS __group__,
+   id AS ticket, summary, component, status, version, severity, time AS created,
+   description AS _description_,
+   changetime AS _changetime, reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status IN ('verified', 'closed')
+AND p.name = t.priority AND p.type = 'priority'
+  ORDER BY (IFNULL(milestone, '') = '') DESC,milestone, p.value, severity, time
 """))
 
 
@@ -347,9 +519,9 @@
 
 # (table, (column1, column2), ((row1col1, row1col2), (row2col1, row2col2)))
 data = (('component',
-             ('name', 'owner'),
-               (('component1', 'somebody'),
-                ('component2', 'somebody'))),
+             ('name', 'owner', 'qaowner'),
+               (('component1', 'somebody', 'qasomebody'),
+                ('component2', 'somebody', 'qasomebody'))),
            ('milestone',
              ('name', 'time'),
                (('', 0), 
@@ -367,7 +539,9 @@
                (('status', 'new', 1),
                 ('status', 'assigned', 2),
                 ('status', 'reopened', 3),
-                ('status', 'closed', 4),
+                ('status', 'resolved', 4),
+                ('status', 'verified', 5),
+                ('status', 'closed', 6),
                 ('resolution', 'fixed', 1),
                 ('resolution', 'invalid', 2),
                 ('resolution', 'wontfix', 3),
@@ -426,6 +600,7 @@
   ('project', 'footer',
    ' Visit the Trac open source project at<br />'
    '<a href="http://trac.edgewall.com/">http://trac.edgewall.com/</a>'),
+  ('ticket', 'workflow', 'trac.workflows.QaRmtWorkflow'),
   ('ticket', 'default_version', ''),
   ('ticket', 'default_severity', 'normal'),
   ('ticket', 'default_priority', 'normal'),
Index: trac/Milestone.py
===================================================================
--- trac/Milestone.py	(revision 1768)
+++ trac/Milestone.py	(working copy)
@@ -60,10 +60,10 @@
     if not group:
         queries['all_tickets'] = env.href.query({'milestone': milestone})
         queries['active_tickets'] = env.href.query({
-            'milestone': milestone, 'status': ['new', 'assigned', 'reopened']
+            'milestone': milestone, 'status': ['new', 'assigned', 'reopened', 'resolved']
         })
         queries['closed_tickets'] = env.href.query({
-            'milestone': milestone, 'status': 'closed'
+            'milestone': milestone, 'status': ['closed', 'verified']
         })
     else:
         queries['all_tickets'] = env.href.query({
@@ -71,17 +71,17 @@
         })
         queries['active_tickets'] = env.href.query({
             'milestone': milestone, grouped_by: group,
-            'status': ['new', 'assigned', 'reopened']
+            'status': ['new', 'assigned', 'reopened', 'resolved']
         })
         queries['closed_tickets'] = env.href.query({
             'milestone': milestone, grouped_by: group,
-            'status': 'closed'
+            'status': ['closed', 'verified']
         })
     return queries
 
 def calc_ticket_stats(tickets):
     total_cnt = len(tickets)
-    active = [ticket for ticket in tickets if ticket['status'] != 'closed']
+    active = [ticket for ticket in tickets if ticket['status'] != 'closed' and ticket['status'] != 'verified']
     active_cnt = len(active)
     closed_cnt = total_cnt - active_cnt
 
@@ -116,10 +116,11 @@
                 if datestr:
                     date = self.parse_date(datestr)
             descr = self.args.get('descr', '')
+            owner = self.args.get('owner', '')
             if not id:
-                self.create_milestone(name, date, descr)
+                self.create_milestone(name, date, descr, owner)
             else:
-                self.update_milestone(id, name, date, descr)
+                self.update_milestone(id, name, date, descr, owner)
         elif id:
             self.req.redirect(self.env.href.milestone(id))
         else:
@@ -141,15 +142,15 @@
                             'Invalid Date Format')
         return seconds
 
-    def create_milestone(self, name, date=0, descr=''):
+    def create_milestone(self, name, date=0, descr='', owner=''):
         self.perm.assert_permission(perm.MILESTONE_CREATE)
         if not name:
             raise TracError('You must provide a name for the milestone.',
                             'Required Field Missing')
         cursor = self.db.cursor()
         self.log.debug("Creating new milestone '%s'" % name)
-        cursor.execute("INSERT INTO milestone (id, name, time, descr) "
-                       "VALUES (NULL, %s, %d, %s)", name, date, descr)
+        cursor.execute("INSERT INTO milestone (id, name, time, descr, owner) "
+                       "VALUES (NULL, %s, %d, %s, %s)", name, date, descr, owner)
         self.db.commit()
         self.req.redirect(self.env.href.milestone(name))
 
@@ -178,7 +179,7 @@
         else:
             self.req.redirect(self.env.href.milestone(id))
 
-    def update_milestone(self, id, name, date, descr):
+    def update_milestone(self, id, name, date, descr, owner):
         self.perm.assert_permission(perm.MILESTONE_MODIFY)
         cursor = self.db.cursor()
         self.log.info("Updating milestone '%s'" % id)
@@ -188,8 +189,8 @@
             cursor.execute('UPDATE ticket SET milestone = %s '
                             'WHERE milestone = %s', name, id)
             cursor.execute("UPDATE milestone SET name = %s, time = %d, "
-                           "descr = %s WHERE name = %s",
-                           name, date, descr, id)
+                           "descr = %s, owner = %s WHERE name = %s",
+                           name, date, descr, owner, id)
             self.db.commit()
             self.req.redirect(self.env.href.milestone(name))
         else:
@@ -222,7 +223,7 @@
 
     def get_milestone(self, name):
         cursor = self.db.cursor()
-        cursor.execute("SELECT name, time, descr FROM milestone "
+        cursor.execute("SELECT name, time, descr, owner FROM milestone "
                        "WHERE name = %s ORDER BY time, name", name)
         row = cursor.fetchone()
         cursor.close()
@@ -237,6 +238,7 @@
         t = row['time'] and int(row['time'])
         if t > 0:
             milestone['date'] = time.strftime('%x', time.localtime(t))
+        milestone['owner'] = row['owner'] or ''
         return milestone
 
     def render(self):
Index: trac/Timeline.py
===================================================================
--- trac/Timeline.py	(revision 1768)
+++ trac/Timeline.py	(working copy)
@@ -52,6 +52,9 @@
         REOPENED_TICKET = 4
         WIKI = 5
         MILESTONE = 6
+        VERIFIED_TICKET = 7
+        RESOLVED_TICKET = 8
+        RETESTED_TICKET = 9
 
         q = []
         if changeset:
@@ -60,26 +63,65 @@
                      "FROM revision WHERE time>=%s AND time<=%s" %
                      (start, stop))
         if tickets:
+            # New tickets
             q.append("SELECT time, id AS idata, '' AS tdata, 2 AS type, "
                      "summary AS message, reporter AS author "
                      "FROM ticket WHERE time>=%s AND time<=%s" %
                      (start, stop))
-            q.append("SELECT time, ticket AS idata, '' AS tdata, 4 AS type, "
-                     "'' AS message, author "
-                     "FROM ticket_change WHERE field='status' "
-                     "AND newvalue='reopened' AND time>=%s AND time<=%s" %
-                     (start, stop))
+            # Reopened tickets
             q.append("SELECT t1.time AS time, t1.ticket AS idata,"
+                     "       '' AS tdata, 4 AS type,"
+                     "       t3.newvalue AS message, t1.author AS author"
+                     " FROM ticket_change t1"
+                     "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
+                     "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
+                     " WHERE t1.field = 'status' AND t1.newvalue = 'reopened'"
+                     "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
+            # Closed tickets (including resolution field for old workflow)
+            q.append("SELECT t1.time AS time, t1.ticket AS idata,"
                      "       t2.newvalue AS tdata, 3 AS type,"
                      "       t3.newvalue AS message, t1.author AS author"
                      " FROM ticket_change t1"
-                     "   INNER JOIN ticket_change t2 ON t1.ticket = t2.ticket"
-                     "     AND t1.time = t2.time"
+                     "   LEFT OUTER JOIN ticket_change t2 ON t1.ticket = t2.ticket"
+                     "     AND t1.time = t2.time AND t2.field = 'resolution'"
                      "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
                      "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
                      " WHERE t1.field = 'status' AND t1.newvalue = 'closed'"
-                     "   AND t2.field = 'resolution'"
                      "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
+            # Verified tickets (including resolution field for customized workflows)
+            q.append("SELECT t1.time AS time, t1.ticket AS idata,"
+                     "       t2.newvalue AS tdata, 7 AS type,"
+                     "       t3.newvalue AS message, t1.author AS author"
+                     " FROM ticket_change t1"
+                     "   LEFT OUTER JOIN ticket_change t2 ON t1.ticket = t2.ticket"
+                     "     AND t1.time = t2.time AND t2.field = 'resolution'"
+                     "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
+                     "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
+                     " WHERE t1.field = 'status' AND t1.newvalue = 'verified'"
+                     "   AND t1.oldvalue<>'closed'"
+                     "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
+            # Resolved tickets (including resolution field)
+            q.append("SELECT t1.time AS time, t1.ticket AS idata,"
+                     "       t2.newvalue AS tdata, 8 AS type,"
+                     "       t3.newvalue AS message, t1.author AS author"
+                     " FROM ticket_change t1"
+                     "   LEFT OUTER JOIN ticket_change t2 ON t1.ticket = t2.ticket"
+                     "     AND t1.time = t2.time AND t2.field = 'resolution'"
+                     "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
+                     "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
+                     " WHERE t1.field = 'status' AND t1.newvalue = 'resolved'"
+                     "   AND t1.oldvalue NOT IN ('verified', 'closed')"
+                     "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
+            # Retested tickets
+            q.append("SELECT t1.time AS time, t1.ticket AS idata,"
+                     "       '' AS tdata, 9 AS type,"
+                     "       t3.newvalue AS message, t1.author AS author"
+                     " FROM ticket_change t1"
+                     "   LEFT OUTER JOIN ticket_change t3 ON t1.time = t3.time"
+                     "     AND t1.ticket = t3.ticket AND t3.field = 'comment'"
+                     " WHERE t1.field = 'status' AND t1.newvalue = 'resolved'"
+                     "   AND t1.oldvalue IN ('verified', 'closed')"
+                     "   AND t1.time >= %s AND t1.time <= %s" % (start,stop))
         if wiki:
             q.append("SELECT time, -1 AS idata, name AS tdata, 5 AS type, "
                      "comment AS message, author "
@@ -87,7 +129,7 @@
                      (start, stop))
         if milestone:
             q.append("SELECT time, -1 AS idata, '' AS tdata, 6 AS type, "
-                     "name AS message, '' AS author " 
+                     "name AS message, owner AS author " 
                      "FROM milestone WHERE time>=%s AND time<=%s" %
                      (start, stop))
 
Index: trac/workflows/Base.py
===================================================================
--- trac/workflows/Base.py	(revision 0)
+++ trac/workflows/Base.py	(revision 0)
@@ -0,0 +1,87 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2003, 2004 Edgewall Software
+# Copyright (C) 2004 Pavel Kourochka <pkou@ua.fm>
+#
+# Trac is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Pavel Kourochka <pkou@ua.fm>
+#
+# Abstract workflow definition
+
+class WorkflowBase:
+    """
+    Generic workflow class for Trac.
+    """
+
+    def __init__(self, env, db, user):
+        """
+        Constructor for workflow class.
+        """
+        self.env = env
+        self.db = db
+        self.user = user
+
+    def get_actions(self, ticket):
+        """
+        For existing tickets only.
+        Return the list of available actions for specified ticket.
+        """
+        raise NotImplementedError
+
+    def do_action(self, ticket, action, args):
+        """
+        For new and existing tickets.
+        Perform action on a ticket.  For new tickets, action name is 'create'.
+        """
+        raise NotImplementedError
+
+    def get_actions_template(self, ticket):
+        """
+        For new and existing tickets.
+        Return the name of ClearSilver template file for the workflow.
+        Return None if no additional template is required.
+        """
+        return None
+
+    def init_template(self, ticket, hdf):
+        """
+        For new and existing tickets.
+        Initialize ClearSilver variables for actions template.
+        Called if get_actions_template() returns file name only.
+        """
+        pass
+
+    def validate(self, ticket, args):
+        """
+        For new and existing tickets.
+        Validate ticket.
+        Return list of Wiki strings that describe errors in the ticket.
+        """
+        return []
+
+    def on_create(self, ticket):
+        """
+        For new tickets only.
+        Update ticket fields just before inserting the ticket into database.
+        """
+        pass
+
+    def on_save(self, ticket):
+        """
+        For existing tickets only.
+        Update ticket fields just before saving the ticket into database.
+        """
+        pass
Index: trac/workflows/QaWorkflow.py
===================================================================
--- trac/workflows/QaWorkflow.py	(revision 0)
+++ trac/workflows/QaWorkflow.py	(revision 0)
@@ -0,0 +1,42 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2003, 2004, 2005 Edgewall Software
+# Copyright (C) 2005 Pavel Kourochka <pkou@ua.fm>
+#
+# Trac is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Pavel Kourochka <pkou@ua.fm>
+#
+# Workflow definition for development and QA teams
+#
+# Note: The workflow is similar to QaRmtWorkflow except that is removes
+#       'verified' status from actions.
+
+from trac.workflows.QaRmtWorkflow import QaRmtWorkflow
+
+class QaWorkflow(QaRmtWorkflow):
+
+    def get_actions(self, ticket):
+        actions = {
+ 'new':      ['leave','reassign','resolve'                  ,'accept'],
+ 'assigned': ['leave','reassign','resolve'                           ],
+ 'reopened': ['leave','reassign','resolve'                  ,'accept'],
+ 'resolved': ['leave','reassign'          ,'reopen'         ,'close' ],
+ 'closed':   ['leave'                     ,'reopen','retest'         ],
+ # Keep the unused state in order to allow ticket processing if workflow
+ # is changed dynamically for existing project.
+ 'verified': ['leave','reassign'          ,'reopen','retest','close' ]
+        }
+        return actions.get(ticket['status'], ['leave'])
Index: trac/workflows/TimeTrackWorkflow.py
===================================================================
--- trac/workflows/TimeTrackWorkflow.py	(revision 0)
+++ trac/workflows/TimeTrackWorkflow.py	(revision 0)
@@ -0,0 +1,135 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2003, 2004 Edgewall Software
+# Copyright (C) 2004 Pavel Kourochka <pkou@ua.fm>
+#
+# Trac is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Pavel Kourochka <pkou@ua.fm>
+#
+# Simple time tracker for any workflow
+#
+# Usage:
+#   1. Add custom field for time calculation
+#      file: project-path/conf/trac.ini
+#      section: [ticket-custom]
+#      parameters:
+#         totaltime = text
+#         totaltime.label = Total time
+#   2. Enable the workflow
+#      file: project-path/conf/trac.ini
+#      section: [ticket]
+#      parameter:
+#          workflow = trac.workflows.TimeTracWorkflow
+#      optional parameter (base workflow that is used for time tracking):
+#          timetrack_workflow = <base workflow, simple workflow by default>
+#      optional parameter (custom field name that is used for time tracking):
+#          timetrack_timefld = <custom field for time, 'totaltime' by default>
+#      optional parameter (require time tracking for every action):
+#          timetrack_required = <'yes' or 'no', default is 'no'>
+
+from trac.workflows.Base import WorkflowBase
+
+class TimeTrackWorkflow(WorkflowBase):
+
+    def __init__(self, env, db, user):
+        WorkflowBase.__init__(self, env, db, user)
+        
+        modulename = env.get_config('ticket', 'timetrack_workflow', \
+                                    'trac.workflows.SimpleWorkflow')
+        i = modulename.rfind('.')
+        if i == -1:
+            classname = modulename
+        else:
+            classname = modulename[i+1:]
+
+        module = __import__(modulename, globals(), locals(), [classname])
+        constructor = getattr(module, classname)
+        self.workflow_proxy = constructor(env, db, user)
+
+        if not isinstance(self.workflow_proxy, WorkflowBase):
+            raise EnvironmentError, "Workflow class %s from %s must be " \
+                                    "descendant of class WorkflowBase from " \
+                                    "trac.workflows.base" \
+                                    % (classname, modulename)
+
+        self.workflow_timefldname = env.get_config( \
+                'ticket', 'timetrack_timefld', 'totaltime')
+        if env.get_config('ticket-custom', self.workflow_timefldname, '') \
+                != 'text':
+            raise EnvironmentError, "TimeTrackWorkflow requires custom " \
+                                    "field %s.  Type of the field is 'text'" \
+                                    % self.workflow_timefldname
+        self.workflow_timefld = 'custom_' + self.workflow_timefldname
+
+        self.time_required = env.get_config('ticket', 'timetrack_required', 'no')
+
+    def get_actions(self, ticket):
+        return self.workflow_proxy.get_actions(ticket)
+
+    def do_action(self, ticket, action, args):
+        self.workflow_proxy.do_action(ticket, action, args)
+
+        curtime = ticket[self.workflow_timefld] or '0'
+        delta = args.get('time_delta') or '0'
+        try:
+            ticket[self.workflow_timefld] = \
+                str(int(curtime, 10) + int(delta, 10))
+        except:
+            self.env.log.warning('TimeTrackWorkflow: invalid time data')
+
+    def get_actions_template(self, ticket):
+        return 'ticket_workflow_timetrack.cs'
+
+    def init_template(self, ticket, hdf):
+        proxytpl = self.workflow_proxy.get_actions_template(ticket)
+        if proxytpl:
+            self.workflow_proxy.init_template(ticket, hdf)
+            hdf.setValue('ticket.workflow.proxy_template', proxytpl)
+
+    def validate(self, ticket, args):
+        err = self.workflow_proxy.validate(ticket, args)
+
+        curtimestr = ticket[self.workflow_timefld]
+        try:
+            curtime = int(curtimestr or '0', 10)
+        except:
+            curtime = -1
+        if curtime < 0:
+            err.append("Total time (custom field '''%s''') must be " \
+                       "non-negative decimal number.  Value '{{{%s}}}' " \
+                       "is incorrect." % (self.workflow_timefldname, \
+                                          curtimestr))
+
+        deltastr = args.get('time_delta')
+        try:
+            delta = int(deltastr or '0', 10)
+        except:
+            delta = -1
+        if delta < 0:
+            err.append("Time that is spent on an action must be " \
+                       "non-negative decimal number.  Value '{{{%s}}}' " \
+                       "is incorrect." % deltastr)
+
+        if self.time_required == 'yes' and not deltastr:
+            err.append("Time that is spent on an action must be entered.")
+
+        return err
+
+    def on_create(self, ticket):
+        self.workflow_proxy.on_create(ticket)
+
+    def on_save(self, ticket):
+        self.workflow_proxy.on_save(ticket)
Index: trac/workflows/QaRmtWorkflow.py
===================================================================
--- trac/workflows/QaRmtWorkflow.py	(revision 0)
+++ trac/workflows/QaRmtWorkflow.py	(revision 0)
@@ -0,0 +1,134 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2003, 2004 Edgewall Software
+# Copyright (C) 2004 Pavel Kourochka <pkou@ua.fm>
+#
+# Trac is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Pavel Kourochka <pkou@ua.fm>
+#
+# Workflow definition for development, QA, and release management teams
+
+from trac.workflows.SimpleWorkflow import SimpleWorkflow
+
+class QaRmtWorkflow(SimpleWorkflow):
+
+    def get_actions(self, ticket):
+        actions = {
+ 'new':      ['leave','reassign','resolve'                  ,'accept'],
+ 'assigned': ['leave','reassign','resolve'                           ],
+ 'reopened': ['leave','reassign','resolve'                  ,'accept'],
+ 'resolved': ['leave','reassign'          ,'reopen'         ,'verify'],
+ 'verified': ['leave','reassign'          ,'reopen','retest','close' ],
+ 'closed':   ['leave'                     ,'reopen','retest'         ]
+        }
+        return actions.get(ticket['status'], ['leave'])
+
+    def do_action(self, ticket, action, args):
+        if action == 'accept':
+            ticket['status'] = 'assigned'
+            ticket['owner'] = self.user
+        elif action == 'resolve':
+            ticket['status'] = 'resolved'
+            ticket['resolution'] = args.get('resolve_resolution')
+            ticket['owner'] = ''
+        elif action == 'verify':
+            ticket['status'] = 'verified'
+            ticket['owner'] = ''
+        elif action == 'close':
+            ticket['status'] = 'closed'
+        elif action == 'reassign':
+            newowner = args.get('reassign_owner')
+            if ticket['owner'] != newowner:
+                if ticket['status'] == 'assigned': ticket['status'] = 'new'
+                ticket['owner'] = newowner
+        elif action == 'reopen':
+            ticket['status'] = 'reopened'
+            ticket['resolution'] = ''
+            ticket['owner'] = ''
+        elif action == 'retest':
+            ticket['status'] = 'resolved'
+            ticket['owner'] = ''
+
+    def get_actions_template(self, ticket):
+        if ticket.has_key('id'):
+            return 'ticket_workflow_qarmt.cs'
+        else:
+            return None
+
+    def on_create(self, ticket):
+        SimpleWorkflow.on_create(self, ticket)
+
+        # The owner field defaults to the milestone owner if
+        # the component does not have any owner
+        cursor = self.db.cursor()
+        if ticket.get('owner', '') == '':
+            cursor.execute('SELECT owner FROM milestone '
+                           'WHERE name=%s', ticket.get('milestone', ''))
+            ticket['owner'] = cursor.fetchone()[0] or ''
+
+    def on_save(self, ticket):
+        SimpleWorkflow.on_save(self, ticket)
+        if not ticket._old: return # Not modified
+
+        cursor = self.db.cursor()
+        status = ticket.get('status', 'new')
+        component = ticket.get('component', '')
+        milestone = ticket.get('milestone', '')
+
+        # If the milestone is changed on a 'new' ticket then owner field
+        # is updated accordingly if the component does not have any owner.
+        # (related to #623).
+        if status == 'new' and ticket._old.has_key('milestone') and \
+               not ticket._old.has_key('component') and \
+               not ticket._old.has_key('owner'):
+            cursor.execute('SELECT owner FROM component '
+                           'WHERE name=%s', component)
+            if not cursor.fetchone()[0]:
+                cursor.execute('SELECT owner FROM milestone '
+                               'WHERE name=%s', ticket._old['milestone'])
+                row = cursor.fetchone()
+                if row:
+                    old_owner = row[0]
+                    if ticket['owner'] == old_owner:
+                        cursor.execute('SELECT owner FROM milestone '
+                                       'WHERE name=%s', milestone)
+                        ticket['owner'] = cursor.fetchone()[0] or ''
+
+        # 1. The owner field defaults to the component owner for active tickets
+        if ticket.get('owner', '') == '' and status in ['new', 'reopened']:
+            cursor.execute('SELECT owner FROM component '
+                           'WHERE name=%s', component)
+            newowner = cursor.fetchone()[0]
+            if newowner: ticket['owner'] = newowner
+
+        # 2. The owner field defaults to component QA owner for testing tickets
+        if ticket.get('owner', '') == '' and status == 'resolved':
+            cursor.execute('SELECT qaowner FROM component '
+                           'WHERE name=%s', component)
+            newowner = cursor.fetchone()[0]
+            if newowner: ticket['owner'] = newowner
+
+        # 3. The owner field defaults to milestone owner for open tickets
+        if ticket.get('owner', '') == '' and status != 'closed':
+            cursor.execute('SELECT owner FROM milestone '
+                           'WHERE name=%s', milestone)
+            newowner = cursor.fetchone()[0]
+            if newowner: ticket['owner'] = newowner
+
+        # 4. The owner field defaults to reporter for verified tickets
+        if ticket.get('owner', '') == '' and status == 'verified':
+            reporter = ticket.get('reporter', '')
+            if reporter: ticket['owner'] = reporter
Index: trac/workflows/__init__.py
===================================================================
--- trac/workflows/__init__.py	(revision 0)
+++ trac/workflows/__init__.py	(revision 0)
@@ -0,0 +1 @@
+__all__ = ['Base', 'SimpleWorkflow', 'TimeTrackWorkflow', 'QaWorkflow', 'QaRmtWorkflow']
Index: trac/workflows/SimpleWorkflow.py
===================================================================
--- trac/workflows/SimpleWorkflow.py	(revision 0)
+++ trac/workflows/SimpleWorkflow.py	(revision 0)
@@ -0,0 +1,102 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2003, 2004 Edgewall Software
+# Copyright (C) 2003, 2004 Jonas Borgström <jonas@edgewall.com>
+#
+# Trac is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+# General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Pavel Kourochka <pkou@ua.fm>
+#
+# Simple workflow definition (as in Trac 0.8)
+
+from trac.workflows.Base import WorkflowBase
+
+class SimpleWorkflow(WorkflowBase):
+
+    def get_actions(self, ticket):
+        actions = {
+            'new':      ['leave' ,'resolve' ,'reassign' ,'accept'],
+            'assigned': ['leave' ,'resolve' ,'reassign'          ],
+            'reopened': ['leave' ,'resolve' ,'reassign'          ],
+            'closed':   ['leave'                        ,'reopen'],
+            # Keep actions for not supported statuses in order to
+            # allow dynamic workflow changes
+            'resolved': ['leave' ,'resolve'             ,'reopen'],
+            'verified': ['leave' ,'resolve'             ,'reopen']
+        }
+        return actions.get(ticket['status'], ['leave'])
+
+    def do_action(self, ticket, action, args):
+        if action == 'accept':
+            ticket['status'] = 'assigned'
+            ticket['owner'] = self.user
+        elif action == 'resolve':
+            ticket['status'] = 'closed'
+            ticket['resolution'] = args.get('resolve_resolution')
+        elif action == 'reassign':
+            ticket['owner'] = args.get('reassign_owner')
+            ticket['status'] = 'new'
+        elif action == 'reopen':
+            ticket['status'] = 'reopened'
+            ticket['resolution'] = ''
+
+    def get_actions_template(self, ticket):
+        if ticket.has_key('id'):
+            return 'ticket_workflow_simple.cs'
+        else:
+            return None
+
+    def init_template(self, ticket, hdf):
+        WorkflowBase.init_template(self, ticket, hdf)
+        if ticket.has_key('id'):
+            for a in self.get_actions(ticket):
+                hdf.setValue('ticket.workflow.action.' + a, '1')
+
+    def validate(self, ticket, args):
+        err = WorkflowBase.validate(self, ticket, args)
+        if not ticket.get('summary'):
+            err.append("The ticket must contain '''Summary''' field.")
+        return err
+
+    def on_create(self, ticket):
+        WorkflowBase.on_create(self, ticket)
+
+        # The owner field defaults to the component owner
+        cursor = self.db.cursor()
+        if ticket.get('owner', '') == '':
+            cursor.execute('SELECT owner FROM component '
+                           'WHERE name=%s', ticket.get('component', ''))
+            ticket['owner'] = cursor.fetchone()[0] or ''
+
+    def on_save(self, ticket):
+        WorkflowBase.on_save(self, ticket)
+        if not ticket._old: return # Not modified
+
+        # If the component is changed on a 'new' ticket then owner field
+        # is updated accordingly. (#623).
+        cursor = self.db.cursor()
+        if ticket['status'] == 'new' and ticket._old.has_key('component') and \
+               not ticket._old.has_key('owner'):
+            cursor.execute('SELECT owner FROM component '
+                           'WHERE name=%s', ticket._old['component'])
+            row = cursor.fetchone()
+            # If the old component has been removed from the database
+            # then we just leave the owner as is.
+            if row:
+                old_owner = row[0]
+                if ticket['owner'] == old_owner:
+                    cursor.execute('SELECT owner FROM component '
+                                   'WHERE name=%s', ticket['component'])
+                    ticket['owner'] = cursor.fetchone()[0]
Index: trac/Roadmap.py
===================================================================
--- trac/Roadmap.py	(revision 1768)
+++ trac/Roadmap.py	(working copy)
@@ -49,14 +49,14 @@
             icalhref += '&show=all'
             self.req.hdf.setValue('roadmap.href.list',
                                    self.env.href.roadmap())
-            query = "SELECT name, time, descr FROM milestone " \
+            query = "SELECT name, time, descr, owner FROM milestone " \
                     "WHERE name != '' " \
                     "ORDER BY (IFNULL(time, 0) = 0) ASC, time ASC, name"
         else:
             self.req.hdf.setValue('roadmap.showall', '1')
             self.req.hdf.setValue('roadmap.href.list',
                                    self.env.href.roadmap('all'))
-            query = "SELECT name, time, descr FROM milestone " \
+            query = "SELECT name, time, descr, owner FROM milestone " \
                     "WHERE name != '' " \
                     "AND (time IS NULL OR time = 0 OR time > %d) " \
                     "ORDER BY (IFNULL(time, 0) = 0) ASC, time ASC, name" % time()
@@ -75,6 +75,7 @@
             milestone = {
                 'name': row['name'],
                 'href': self.env.href.milestone(row['name']),
+                'owner': row['owner'] or '',
                 'time': row['time'] and int(row['time'])
             }
             descr = row['descr']
@@ -114,7 +115,7 @@
             status = ticket['status']
             if status == 'new' or status == 'reopened' and not ticket['owner']:
                 return 'NEEDS-ACTION'
-            elif status == 'assigned' or status == 'reopened':
+            elif status != 'closed':
                 return 'IN-PROCESS'
             elif status == 'closed':
                 if ticket['resolution'] == 'fixed': return 'COMPLETED'
Index: trac/upgrades/db8.py
===================================================================
--- trac/upgrades/db8.py	(revision 0)
+++ trac/upgrades/db8.py	(revision 0)
@@ -0,0 +1,279 @@
+sql = """
+-- Add statuses 'resolved' and 'verified'
+UPDATE enum SET value = 6 WHERE type = 'status' AND name = 'closed';
+INSERT INTO enum (type, name, value) VALUES ('status', 'resolved', 4);
+INSERT INTO enum (type, name, value) VALUES ('status', 'verified', 5);
+
+-- Add QA Contact to 'component'
+CREATE TEMPORARY TABLE component_backup AS SELECT * FROM component;
+DROP TABLE component;
+CREATE TABLE component (
+         name            text PRIMARY KEY,
+         owner           text,
+         qaowner         text
+);
+INSERT INTO component SELECT name, owner, owner AS qaowner FROM component_backup;
+DROP TABLE component_backup;
+
+-- Add Release Manager Contact to 'milestone'
+CREATE TEMPORARY TABLE milestone_backup AS SELECT * FROM milestone;
+DROP TABLE milestone;
+CREATE TABLE milestone (
+         id              integer PRIMARY KEY,
+         name            text,
+         time            integer,
+         owner           text,
+         descr           text,
+         UNIQUE(name)
+);
+INSERT INTO milestone SELECT id, name, time, '' AS owner, descr FROM milestone_backup;
+DROP TABLE milestone_backup;
+
+-- Modify 'All Tickets by Version' report
+UPDATE report SET sql = '
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(version, '''') = '''' THEN ''Not Specified'' ELSE ''Version '' || version END) AS __group__,
+   id AS ticket, summary, component, milestone, severity, 
+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status IN (''new'', ''assigned'', ''reopened'') 
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY (IFNULL(version, '''') = '''') DESC,version, p.value, severity, time
+' WHERE title = 'Active Tickets by Version';
+
+-- Modify 'All Tickets by Milestone' report
+UPDATE report SET sql = '
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(milestone, '''') = '''' THEN ''Not Assigned'' ELSE milestone||'' Release'' END) AS __group__,
+   id AS ticket, summary, component, version, severity, 
+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status IN (''new'', ''assigned'', ''reopened'') 
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY (IFNULL(milestone, '''') = '''') DESC,milestone, p.value, severity, time
+', title = 'Active Tickets by Milestone'
+WHERE title = 'All Tickets by Milestone';
+
+-- Modify 'Assigned, Active Tickets by Owner' report
+UPDATE report SET sql = '
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(owner, '''') = '''' THEN ''Not Assigned'' ELSE owner END) AS __group__,
+   id AS ticket, summary, component, version, milestone, severity, time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t,enum p
+  WHERE status = ''assigned''
+AND p.name=t.priority AND p.type=''priority''
+  ORDER BY (IFNULL(owner, '''') = '''') DESC, owner, p.value, severity, time
+' WHERE title = 'Assigned, Active Tickets by Owner';
+
+-- Modify 'Assigned, Active Tickets by Owner (Full Description)' report
+UPDATE report SET sql = '
+SELECT p.value AS __color__,
+   owner AS __group__,
+   id AS ticket, summary, component, version, milestone, severity, time AS created,
+   description AS _description_,
+   changetime AS _changetime, reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status = ''assigned''
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY owner, p.value, severity, time
+' WHERE title = 'Assigned, Active Tickets by Owner (Full Description)';
+
+-- Modify 'All Tickets By Milestone  (Including closed)' report
+UPDATE report SET sql = '
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(t.milestone, '''') = '''' THEN ''Not Assigned'' ELSE t.milestone || '' Release'' END) AS __group__,
+   (CASE status 
+      WHEN ''closed'' THEN ''color: #777; background: #ddd; border-color: #ccc;''
+      ELSE 
+        (CASE owner WHEN ''$USER'' THEN ''font-weight: bold'' END)
+    END) AS __style__,
+   id AS ticket, summary, component, status, 
+   (CASE WHEN resolution ISNULL THEN '' ELSE resolution END) AS resolution,
+   version, severity, priority, owner,
+   changetime AS modified,
+   time AS _time,reporter AS _reporter
+  FROM ticket t,enum p
+  WHERE p.name=t.priority AND p.type=''priority''
+  ORDER BY (IFNULL(milestone, '''') = '''') DESC, milestone DESC, (status = ''closed''), 
+        (CASE status WHEN ''closed'' THEN modified ELSE -p.value END) DESC
+' WHERE title = 'All Tickets By Milestone  (Including closed)';
+
+-- Modify 'My Tickets' report
+UPDATE report SET sql = '
+SELECT p.value AS __color__,
+   (CASE WHEN status <> ''new'' THEN ''Assigned''
+         ELSE ''Owned in '' || IFNULL(milestone, ''N/A'')
+    END) AS __group__,
+   id AS ticket, summary, component, status, resolution, version, milestone,
+   priority, time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE t.status <> ''closed'' 
+AND p.name = t.priority AND p.type = ''priority'' AND owner = ''$USER''
+  ORDER BY (status = ''new''), (IFNULL(milestone, '''') = '''') DESC,
+           milestone, p.value, resolution, time
+' WHERE title = 'My Tickets';
+
+-- New reports
+
+INSERT INTO report VALUES(NULL,NULL,'Open Tickets, Mine first','
+SELECT p.value AS __color__,
+   (CASE owner 
+     WHEN ''$USER'' THEN ''My Tickets'' 
+     ELSE ''Open Tickets'' 
+    END) AS __group__,
+   id AS ticket, summary, component, status, version, milestone, severity, 
+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status <> ''closed'' 
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY (owner = ''$USER'') DESC, p.value, milestone, severity, time
+','
+ * List all not closed tickets by priority.
+ * Show all tickets owned by the logged in user in a group first.
+');
+
+INSERT INTO report VALUES(NULL,NULL,'Open Tickets by Version','
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(version, '''') = '''' THEN ''Not Specified'' ELSE ''Version '' || version END) AS __group__,
+   id AS ticket, summary, component, status, milestone, severity, 
+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status <> ''closed''
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY (IFNULL(version, '''') = '''') desc,version, p.value, severity, time
+','
+ * List all not closed tickets by priority.
+ * Group results by version.
+');
+
+INSERT INTO report VALUES(NULL,NULL,'Open Tickets by Milestone','
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(milestone, '''') = '''' THEN ''Not Assigned'' ELSE milestone||'' Release'' END) AS __group__,
+   id AS ticket, summary, component, status, version, severity, 
+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status <> ''closed'' 
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY (IFNULL(milestone, '''') = '''') DESC,milestone, p.value, severity, time
+','
+ * List all not closed tickets by priority.
+ * Group results by milestone.
+');
+
+INSERT INTO report VALUES(NULL,NULL,'Open Tickets by Owner','
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(owner, '''') = '''' THEN ''Not Assigned'' ELSE owner END) AS __group__,
+   id AS ticket, summary, component, status, version, milestone, severity,
+   time AS created, changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t,enum p
+  WHERE status <> ''closed''
+AND p.name=t.priority AND p.type=''priority''
+  ORDER BY (IFNULL(owner, '''') = '''') DESC, owner, p.value, severity, time
+','
+List not closed tickets, group by ticket owner, sorted by priority.
+');
+
+INSERT INTO report VALUES(NULL,NULL,'Open Tickets by Status','
+SELECT p.value AS __color__,
+   status AS __group__,
+   id AS ticket, summary, component, version, milestone, severity, owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum q, enum p
+  WHERE status <> ''closed'' 
+AND q.name = t.status AND q.type = ''status''
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY q.value, p.value, severity, time
+','
+ * List all not closed tickets by priority.
+ * Group results by status.
+');
+
+INSERT INTO report VALUES(NULL,NULL,'Resolved Tickets, Mine first','
+SELECT p.value AS __color__,
+   (CASE owner 
+     WHEN ''$USER'' THEN ''My Tickets'' 
+     ELSE ''Active Tickets'' 
+    END) AS __group__,
+   id AS ticket, summary, component, version, milestone, severity, 
+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status = ''resolved'' 
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY (owner = ''$USER'') DESC, p.value, milestone, severity, time
+','
+ * List all resolved tickets by priority.
+ * Show all tickets owned by the logged in user in a group first.
+');
+
+INSERT INTO report VALUES(NULL,NULL,'Resolved Tickets by Milestone','
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(milestone, '''') = '''' THEN ''Not Assigned'' ELSE milestone||'' Release'' END) AS __group__,
+   id AS ticket, summary, component, version, severity, 
+   (CASE status WHEN ''assigned'' THEN owner||'' *'' ELSE owner END) AS owner,
+   time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status = ''resolved'' 
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY (IFNULL(milestone, '''') = '''') DESC,milestone, p.value, severity, time
+','
+List resolved tickets, sorted by priority, grouped by milestone
+');
+
+INSERT INTO report VALUES(NULL,NULL,'Resolved Tickets by Owner','
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(owner, '''') = '''' THEN ''Not Assigned'' ELSE owner END) AS __group__,
+   id AS ticket, summary, component, version, milestone, severity, time AS created,
+   changetime AS _changetime, description AS _description,
+   reporter AS _reporter
+  FROM ticket t,enum p
+  WHERE status = ''resolved''
+AND p.name=t.priority AND p.type=''priority''
+  ORDER BY (IFNULL(owner, '''') = '''') DESC, owner, p.value, severity, time
+','
+List resolved tickets, group by ticket owner, sorted by priority.
+');
+
+INSERT INTO report VALUES(NULL,NULL,'Completed Tickets by Milestone (Full Description)','
+SELECT p.value AS __color__,
+   (CASE WHEN IFNULL(milestone, '''') = '''' THEN ''Not Assigned'' ELSE milestone||'' Release'' END) AS __group__,
+   id AS ticket, summary, component, status, version, severity, time AS created,
+   description AS _description_,
+   changetime AS _changetime, reporter AS _reporter
+  FROM ticket t, enum p
+  WHERE status IN (''verified'', ''closed'')
+AND p.name = t.priority AND p.type = ''priority''
+  ORDER BY (IFNULL(milestone, '''') = '''') DESC,milestone, p.value, severity, time
+','
+Release Notes: List verified and closed tickets, group by milestone, include description.
+');
+"""
+
+def do_upgrade(env, ver, cursor):
+    cursor.execute(sql)
Index: trac/upgrades/__init__.py
===================================================================
--- trac/upgrades/__init__.py	(revision 1768)
+++ trac/upgrades/__init__.py	(working copy)
@@ -1 +1 @@
-__all__ = ['db2', 'db3', 'db4', 'db5', 'db6', 'db7']
+__all__ = ['db2', 'db3', 'db4', 'db5', 'db6', 'db7', 'db8']
Index: trac/Ticket.py
===================================================================
--- trac/Ticket.py	(revision 1768)
+++ trac/Ticket.py	(working copy)
@@ -51,6 +51,8 @@
             return
         if not self._old.has_key(name):
             self._old[name] = self.get(name, None)
+        elif self._old[name] == value:
+            del self._old[name]
         self.data[name] = value
 
     def _forget_changes(self):
@@ -130,22 +132,6 @@
 
         if not self._old and not comment: return # Not modified
 
-        # If the component is changed on a 'new' ticket then owner field
-        # is updated accordingly. (#623).
-        if self['status'] == 'new' and self._old.has_key('component') and \
-               not self._old.has_key('owner'):
-            cursor.execute('SELECT owner FROM component '
-                           'WHERE name=%s', self._old['component'])
-            row = cursor.fetchone()
-            # If the old component has been removed from the database
-            # then we just leave the owner as is.
-            if row:
-                old_owner = row[0]
-                if self['owner'] == old_owner:
-                    cursor.execute('SELECT owner FROM component '
-                                   'WHERE name=%s', self['component'])
-                    self['owner'] = cursor.fetchone()[0]
-
         for name in self._old.keys():
             if name[:7] == 'custom_':
                 fname = name[7:]
@@ -268,25 +254,38 @@
         i += 1
 
 
+def get_workflow(env, db, user):
+#    from trac.workflows.Simple import SimpleWorkflow
+#    return SimpleWorkflow(env, db, user)
+    modulename = env.get_config('ticket', 'workflow', \
+                                'trac.workflows.SimpleWorkflow')
+    i = modulename.rfind('.')
+    if i == -1:
+        classname = modulename
+    else:
+        classname = modulename[i+1:]
+
+    module = __import__(modulename, globals(), locals(), [classname])
+    constructor = getattr(module, classname)
+    workflow = constructor(env, db, user)
+
+    from workflows.Base import WorkflowBase
+    if not isinstance(workflow, WorkflowBase):
+        raise EnvironmentError, "Workflow class %s from %s must be " \
+                                "descendant of class WorkflowBase from " \
+                                "trac.workflows.base" \
+                                % (classname, modulename)
+
+    return workflow
+
+
 class NewticketModule(Module):
     template_name = 'newticket.cs'
 
-    def create_ticket(self):
-        if not self.args.get('summary'):
-            raise util.TracError('Tickets must contain Summary.')
-
-        ticket = Ticket()
-        ticket.populate(self.args)
+    def create_ticket(self, ticket, workflow):
         ticket.setdefault('reporter',self.req.authname)
 
-        # The owner field defaults to the component owner
-        cursor = self.db.cursor()
-        if ticket.get('component') and ticket.get('owner', '') == '':
-            cursor.execute('SELECT owner FROM component '
-                           'WHERE name=%s', ticket['component'])
-            owner = cursor.fetchone()[0]
-            ticket['owner'] = owner
-
+        workflow.on_create(ticket)
         tktid = ticket.insert(self.db)
 
         # Notify
@@ -298,11 +297,25 @@
     def render (self):
         self.perm.assert_permission(perm.TICKET_CREATE)
 
-        if self.args.has_key('create'):
-            self.create_ticket()
+        ticket = Ticket()
 
-        ticket = Ticket()
+        preview = self.args.has_key('preview')
+        do_create = self.args.has_key('create')
         ticket.populate(self.args)
+
+        workflow = get_workflow(self.env, self.db, self.req.authname)
+
+        # Validate the ticket
+        err = []
+        if preview or do_create:
+            err.extend(workflow.validate(ticket, self.args))
+        if len(err) != 0: preview = 1
+
+        # Create the ticket if not in preview mode
+        if not preview and do_create:
+            workflow.do_action(ticket, 'create', self.args)
+            self.create_ticket(ticket, workflow)
+
         ticket.setdefault('component',
                           self.env.get_config('ticket', 'default_component'))
         ticket.setdefault('milestone',
@@ -324,6 +337,14 @@
         evals = util.mydict(zip(ticket.keys(),
                                 map(lambda x: util.escape(x), ticket.values())))
         util.add_to_hdf(evals, self.req.hdf, 'newticket')
+        if len(err) != 0:
+            self.req.hdf.setValue('newticket.workflow.error',
+                              wiki_to_html(' * ' + '\n * '.join(err),
+                                           self.req.hdf, self.env, self.db))
+        tpl = workflow.get_actions_template(ticket)
+        if tpl:
+            self.req.hdf.setValue('newticket.workflow.template', tpl)
+            workflow.init_template(ticket, self.req.hdf)
 
         util.sql_to_hdf(self.db, 'SELECT name FROM component ORDER BY name',
                         self.req.hdf, 'newticket.components')
@@ -338,38 +359,18 @@
 class TicketModule (Module):
     template_name = 'ticket.cs'
 
-    def save_changes (self, id):
+    def save_changes (self, ticket, workflow):
         self.perm.assert_permission (perm.TICKET_MODIFY)
-        ticket = Ticket(self.db, id)
 
-        if not self.args.get('summary'):
-            raise util.TracError('Tickets must contain Summary.')
-
         if self.args.has_key('description'):
             self.perm.assert_permission (perm.TICKET_ADMIN)
 
         if self.args.has_key('reporter'):
             self.perm.assert_permission (perm.TICKET_ADMIN)
 
-        # TODO: this should not be hard-coded like this
-        action = self.args.get('action', None)
-        if action == 'accept':
-            ticket['status'] =  'assigned'
-            ticket['owner'] = self.req.authname
-        if action == 'resolve':
-            ticket['status'] = 'closed'
-            ticket['resolution'] = self.args.get('resolve_resolution')
-        elif action == 'reassign':
-            ticket['owner'] = self.args.get('reassign_owner')
-            ticket['status'] = 'new'
-        elif action == 'reopen':
-            ticket['status'] = 'reopened'
-            ticket['resolution'] = ''
-
-        ticket.populate(self.args)
-
         now = int(time.time())
 
+        workflow.on_save(ticket)
         ticket.save_changes(self.db,
                             self.args.get('author', self.req.authname),
                             self.args.get('comment'),
@@ -377,7 +378,7 @@
 
         tn = TicketNotifyEmail(self.env)
         tn.notify(ticket, newticket=0, modtime=now)
-        self.req.redirect(self.env.href.ticket(id))
+        self.req.redirect(self.env.href.ticket(ticket['id']))
 
     def insert_ticket_data(self, hdf, id, ticket, reporter_id):
         """Insert ticket data into the hdf"""
@@ -437,27 +438,39 @@
     def render (self):
         self.perm.assert_permission (perm.TICKET_VIEW)
 
-        action = self.args.get('action', 'view')
-        preview = self.args.has_key('preview')
-
         if not self.args.has_key('id'):
             self.req.redirect(self.env.href.wiki())
 
         id = int(self.args.get('id'))
+        ticket = Ticket(self.db, id)
 
-        if not preview \
-               and action in ['leave', 'accept', 'reopen', 'resolve', 'reassign']:
-            self.save_changes (id)
+        action = self.args.get('action', None)
+        preview = self.args.has_key('preview')
+        if action or preview:
+            ticket.populate(self.args)
 
-        ticket = Ticket(self.db, id)
+        workflow = get_workflow(self.env, self.db, self.req.authname)
+
+        # Validate ticket
+        err = []
+        if action or preview:
+            actions = workflow.get_actions(ticket)
+            if action not in actions:
+                err.append("Invalid action '''%s''' is performed on the ticket. " \
+                           "Allowed actions are <''%s''>." % \
+                           (action, ', '.join(actions)))
+            err.extend(workflow.validate(ticket, self.args))
+        if len(err) != 0: preview = 1
+
+        # Save changes if not in preview mode
+        if not preview and action:
+            workflow.do_action(ticket, action, self.args)
+            self.save_changes(ticket, workflow)
+
         reporter_id = util.get_reporter_id(self.req)
 
         if preview:
-            # Use user supplied values
-            for field in Ticket.std_fields:
-                if self.args.has_key(field) and field != 'reporter':
-                    ticket[field] = self.args.get(field)
-            self.req.hdf.setValue('ticket.action', action)
+            if action: self.req.hdf.setValue('ticket.action', action)
             reporter_id = self.args.get('author')
             comment = self.args.get('comment')
             if comment:
@@ -468,6 +481,14 @@
                                                self.req.hdf, self.env, self.db))
 
         self.insert_ticket_data(self.req.hdf, id, ticket, reporter_id)
+        if len(err) != 0:
+            self.req.hdf.setValue('ticket.workflow.error',
+                              wiki_to_html(' * ' + '\n * '.join(err),
+                                           self.req.hdf, self.env, self.db))
+        tpl = workflow.get_actions_template(ticket)
+        if tpl:
+            self.req.hdf.setValue('ticket.workflow.template', tpl)
+            workflow.init_template(ticket, self.req.hdf)
 
         cursor = self.db.cursor()
         cursor.execute("SELECT max(id) FROM ticket")
Index: trac/WikiFormatter.py
===================================================================
--- trac/WikiFormatter.py	(revision 1768)
+++ trac/WikiFormatter.py	(working copy)
@@ -125,7 +125,7 @@
             elif row[1] == 'closed':
                 return '<a href="%s" title="CLOSED : %s"><del>#%d</del></a>' % (self._href.ticket(number), summary, number)
             else:
-                return '<a href="%s" title="%s">#%d</a>' % (self._href.ticket(number), summary, number)
+                return '<a href="%s" title="%s : %s">#%d</a>' % (self._href.ticket(number), row[1].upper(), summary, number)
 
     def _changesethref_formatter(self, match, fullmatch):
         number = int(match[1:-1])
@@ -158,7 +158,7 @@
                 elif row[1] == 'closed':
                     return self._href.ticket(args), '<del>%s:%s</del>' % (module, args), 0, 'CLOSED: ' + summary
                 else:
-                    return self._href.ticket(args), '%s:%s' % (module, args), 0, summary
+                    return self._href.ticket(args), '%s:%s' % (module, args), 0, row[1].upper() + ': ' + summary
             else:
                 return self._href.ticket(args), '%s:%s' % (module, args), 1, ''
         elif module == 'wiki':
Index: templates/ticket_workflow_timetrack.cs
===================================================================
--- templates/ticket_workflow_timetrack.cs	(revision 0)
+++ templates/ticket_workflow_timetrack.cs	(revision 0)
@@ -0,0 +1,10 @@
+<?cs
+if ticket.workflow.proxy_template ?><?cs
+  include ticket.workflow.proxy_template ?><?cs
+/if ?>
+
+<div>
+  <label for="time_delta">action time:</label>
+  <input type="text" id="time_delta" name="time_delta" size="10"
+         value="<?cs var args.time_delta ?>" />
+</div>
Index: templates/ticket_workflow_qarmt.cs
===================================================================
--- templates/ticket_workflow_qarmt.cs	(revision 0)
+++ templates/ticket_workflow_qarmt.cs	(revision 0)
@@ -0,0 +1,102 @@
+<?cs
+if !ticket.action ?><?cs
+  set:ticket.action = 'leave' ?><?cs
+/if ?><?cs
+def action_radio(id) ?>
+  <input type="radio" id="<?cs var id ?>" name="action" value="<?cs var id ?>"
+    <?cs if $ticket.action == $id ?> checked="checked"<?cs /if ?> /><?cs
+/def ?>
+
+<?cs
+if ticket.workflow.action.leave ?><?cs
+  call:action_radio('leave') ?>
+  <label for="leave">leave as <?cs var:ticket.status ?></label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.accept ?><?cs
+  call action_radio('accept') ?>
+  <label for="accept">accept ticket</label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.resolve ?><?cs
+  call:action_radio('resolve') ?>
+  <label for="resolve">resolve</label>
+  <label for="resolve_resolution">as:</label><?cs
+  call:hdf_select(enums.resolution, "resolve_resolution",
+                  args.resolve_resolution) ?><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.verify ?><?cs
+  call action_radio('verify') ?>
+  <label for="verify">verify ticket</label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.close ?><?cs
+  call action_radio('close') ?>
+  <label for="close">close ticket</label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.reopen ?><?cs
+  call:action_radio('reopen') ?>
+  <label for="reopen">reopen ticket</label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.retest ?><?cs
+  call:action_radio('retest') ?>
+  <label for="retest">retest ticket</label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.reassign ?><?cs
+  call:action_radio('reassign') ?>
+  <label for="reassign">reassign</label>
+  <label for="reassign_owner">to:</label><?cs
+  if:args.reassign_to ?><?cs
+    set default_owner=args.reassign_to ?><?cs
+  else ?><?cs
+    set default_owner=trac.authname ?><?cs
+  /if ?><?cs
+  if:len(ticket.userlist) ?><?cs
+    call:hdf_select(ticket.userlist, "reassign_owner", default_owner) ?><?cs
+  else ?>
+    <input type="text" id="reassign_owner" name="reassign_owner" size="40"
+           value="<?cs var:default_owner ?>" /><?cs
+  /if ?><?cs
+/if ?>
+
+<?cs
+if ticket.workflow.action.resolve || ticket.workflow.action.reassign ?>
+  <script type="text/javascript"><?cs
+  if ticket.workflow.action.resolve ?>
+    var resolve = document.getElementById("resolve");<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reassign ?>
+    var reassign = document.getElementById("reassign");<?cs
+  /if ?>
+    var updateActionFields = function() {<?cs
+  if ticket.workflow.action.resolve ?>
+      enableControl('resolve_resolution', resolve.checked);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reassign ?>
+      enableControl('reassign_owner', reassign.checked);<?cs
+  /if ?>
+    };
+    addEvent(window, 'load', updateActionFields);<?cs
+  if ticket.workflow.action.leave ?>
+    addEvent(document.getElementById("leave"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.accept ?>
+    addEvent(document.getElementById("accept"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.resolve ?>
+    addEvent(resolve, 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.verify ?>
+    addEvent(document.getElementById("verify"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.close ?>
+    addEvent(document.getElementById("close"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reopen ?>
+    addEvent(document.getElementById("reopen"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.retest ?>
+    addEvent(document.getElementById("retest"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reassign ?>
+    addEvent(reassign, 'click', updateActionFields);<?cs
+  /if ?>
+  </script>
+<?cs /if ?>
Index: templates/ticket.cs
===================================================================
--- templates/ticket.cs	(revision 1768)
+++ templates/ticket.cs	(working copy)
@@ -33,7 +33,7 @@
 <div id="ticket">
  <div class="date"><?cs var:ticket.opened ?></div>
  <h1>Ticket #<?cs var:ticket.id ?> <?cs
- if:ticket.status == 'closed' ?>(Closed: <?cs var:ticket.resolution ?>)<?cs
+ if:ticket.resolution ?>(<?cs var:ticket.status ?>: <?cs var:ticket.resolution ?>)<?cs
  elif:ticket.status != 'new' ?>(<?cs var:ticket.status ?>)<?cs
  /if ?></h1>
  <h2><?cs var:ticket.summary ?></h2>
@@ -207,56 +207,22 @@
   </div><?cs /if ?>
  </fieldset>
 
- <fieldset id="action">
-  <legend>Action</legend><?cs
-  if:!ticket.action ?><?cs set:ticket.action = 'leave' ?><?cs
-  /if ?><?cs
-  def:action_radio(id) ?>
-   <input type="radio" id="<?cs var:id ?>" name="action" value="<?cs
-     var:id ?>"<?cs if:$ticket.action == $id ?> checked="checked"<?cs
-     /if ?> /><?cs
-  /def ?>
-  <?cs call:action_radio('leave') ?>
-  <label for="leave">leave as <?cs var:ticket.status ?></label><br /><?cs
-  if $ticket.status == "new" ?>
-   <?cs call:action_radio('accept') ?>
-   <label for="accept">accept ticket</label><br /><?cs
-  /if ?><?cs
-  if $ticket.status == "closed" ?>
-   <?cs call:action_radio('reopen') ?>
-   <label for="reopen">reopen ticket</label><br /><?cs
-  /if ?><?cs
-  if $ticket.status == "new" || $ticket.status == "assigned" || $ticket.status == "reopened" ?>
-   <?cs call:action_radio('resolve') ?>
-   <label for="resolve">resolve</label>
-   <label for="resolve_resolution">as:</label>
-   <?cs call:hdf_select(enums.resolution, "resolve_resolution", args.resolve_resolution) ?><br />
-   <?cs call:action_radio('reassign') ?>
-   <label for="reassign">reassign</label>
-   <label for="reassign_owner">to:</label>
-   <input type="text" id="reassign_owner" name="reassign_owner" size="40" value="<?cs
-     if:args.reassign_to ?><?cs var:args.reassign_to ?><?cs
-     else ?><?cs var:trac.authname ?><?cs /if ?>" /><?cs
-  /if ?><?cs
-  if $ticket.status == "new" || $ticket.status == "assigned" || $ticket.status == "reopened" ?>
-   <script type="text/javascript">
-     var resolve = document.getElementById("resolve");
-     var reassign = document.getElementById("reassign");
-     var updateActionFields = function() {
-       enableControl('resolve_resolution', resolve.checked);
-       enableControl('reassign_owner', reassign.checked);
-     };
-     addEvent(window, 'load', updateActionFields);
-     addEvent(document.getElementById("leave"), 'click', updateActionFields);<?cs
-    if $ticket.status == "new" ?>
-     addEvent(document.getElementById("accept"), 'click', updateActionFields);<?cs
-    /if ?>
-    addEvent(resolve, 'click', updateActionFields);
-    addEvent(reassign, 'click', updateActionFields);
-   </script><?cs
-  /if ?>
- </fieldset>
+ <?cs if ticket.workflow.template ?>
+  <fieldset id="action">
+   <legend>Action</legend>
+   <?cs include ticket.workflow.template ?>
+  </fieldset>
+ <?cs /if ?>
 
+ <?cs if ticket.workflow.error ?>
+   <div class="system-message">
+     <h2>Ticket Error</h2>
+     <p class="message"><?cs var ticket.workflow.error ?></p>
+     <strong>The ticket will not be saved.</strong>
+   </div>
+ <?cs /if ?>
+
+
  <div class="buttons">
   <input type="reset" value="Reset" />&nbsp;
   <input type="submit" name="preview" value="Preview" />&nbsp;
Index: templates/roadmap.cs
===================================================================
--- templates/roadmap.cs	(revision 1768)
+++ templates/roadmap.cs	(working copy)
@@ -22,6 +22,7 @@
       var:milestone.name ?></em></a></h2>
     <p class="date"><?cs if:milestone.date ?>
      <?cs var:milestone.date ?><?cs else ?>No date set<?cs /if ?>
+     <?cs if:milestone.owner ?>&nbsp;(<?cs var:milestone.owner ?>)<?cs /if ?>
     </p>
     <?cs with:stats = milestone.stats ?>
      <?cs if:#stats.total_tickets > #0 ?>
Index: templates/ticket_workflow_simple.cs
===================================================================
--- templates/ticket_workflow_simple.cs	(revision 0)
+++ templates/ticket_workflow_simple.cs	(revision 0)
@@ -0,0 +1,81 @@
+<?cs
+if !ticket.action ?><?cs
+  set:ticket.action = 'leave' ?><?cs
+/if ?><?cs
+def action_radio(id) ?>
+  <input type="radio" id="<?cs var id ?>" name="action" value="<?cs var id ?>"
+    <?cs if $ticket.action == $id ?> checked="checked"<?cs /if ?> /><?cs
+/def ?>
+
+<?cs
+if ticket.workflow.action.leave ?><?cs
+  call:action_radio('leave') ?>
+  <label for="leave">leave as <?cs var:ticket.status ?></label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.accept ?><?cs
+  call action_radio('accept') ?>
+  <label for="accept">accept ticket</label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.resolve ?><?cs
+  call:action_radio('resolve') ?>
+  <label for="resolve">resolve</label>
+  <label for="resolve_resolution">as:</label><?cs
+  call:hdf_select(enums.resolution, "resolve_resolution",
+                  args.resolve_resolution) ?><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.reopen ?><?cs
+  call:action_radio('reopen') ?>
+  <label for="reopen">reopen ticket</label><br /><?cs
+/if ?><?cs
+if ticket.workflow.action.reassign ?><?cs
+  call:action_radio('reassign') ?>
+  <label for="reassign">reassign</label>
+  <label for="reassign_owner">to:</label><?cs
+  if:args.reassign_to ?><?cs
+    set default_owner=args.reassign_to ?><?cs
+  else ?><?cs
+    set default_owner=trac.authname ?><?cs
+  /if ?><?cs
+  if:len(ticket.userlist) ?><?cs
+    call:hdf_select(ticket.userlist, "reassign_owner", default_owner) ?><?cs
+  else ?>
+    <input type="text" id="reassign_owner" name="reassign_owner" size="40"
+           value="<?cs var:default_owner ?>" /><?cs
+  /if ?><?cs
+/if ?>
+
+<?cs
+if ticket.workflow.action.resolve || ticket.workflow.action.reassign ?>
+  <script type="text/javascript"><?cs
+  if ticket.workflow.action.resolve ?>
+    var resolve = document.getElementById("resolve");<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reassign ?>
+    var reassign = document.getElementById("reassign");<?cs
+  /if ?>
+    var updateActionFields = function() {<?cs
+  if ticket.workflow.action.resolve ?>
+      enableControl('resolve_resolution', resolve.checked);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reassign ?>
+      enableControl('reassign_owner', reassign.checked);<?cs
+  /if ?>
+    };
+    addEvent(window, 'load', updateActionFields);<?cs
+  if ticket.workflow.action.leave ?>
+    addEvent(document.getElementById("leave"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.accept ?>
+    addEvent(document.getElementById("accept"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.resolve ?>
+    addEvent(resolve, 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reopen ?>
+    addEvent(document.getElementById("reopen"), 'click', updateActionFields);<?cs
+  /if ?><?cs
+  if ticket.workflow.action.reassign ?>
+    addEvent(reassign, 'click', updateActionFields);<?cs
+  /if ?>
+  </script>
+<?cs /if ?>
Index: templates/timeline_rss.cs
===================================================================
--- templates/timeline_rss.cs	(revision 1768)
+++ templates/timeline_rss.cs	(working copy)
@@ -43,12 +43,24 @@
                              $item.href, $item.msg_escwiki) 
         ?><?cs elif:item.type == #3
         ?><!-- Closed ticket --> <?cs call:rss_item('Ticket',
-                             'Ticket #'+$item.idata+' resolved: '+$item.shortmsg,
+                             'Ticket #'+$item.idata+' closed: '+$item.shortmsg,
                              $item.href, $item.msg_escwiki) 
         ?><?cs elif:item.type == #4 
         ?><!-- Reopened ticket --><?cs call:rss_item('Ticket',
                              '#'+$item.idata+' reopened: '+$item.shortmsg,
                              $item.href, $item.msg_escwiki) 
+        ?><?cs elif:item.type == #7 
+        ?><!-- Verified ticket --><?cs call:rss_item('Ticket',
+                             '#'+$item.idata+' verified: '+$item.shortmsg,
+                             $item.href, $item.msg_escwiki) 
+        ?><?cs elif:item.type == #8
+        ?><!-- Resolved ticket --> <?cs call:rss_item('Ticket',
+                             'Ticket #'+$item.idata+' resolved: '+$item.shortmsg,
+                             $item.href, $item.msg_escwiki) 
+        ?><?cs elif:item.type == #9 
+        ?><!-- Retested ticket --><?cs call:rss_item('Ticket',
+                             '#'+$item.idata+' retested: '+$item.shortmsg,
+                             $item.href, $item.msg_escwiki) 
         ?><?cs elif:item.type == #5 
         ?><!-- Wiki change --><?cs call:rss_item('Wiki',
                              $item.tdata+" page edited.",
Index: templates/newticket.cs
===================================================================
--- templates/newticket.cs	(revision 1768)
+++ templates/newticket.cs	(working copy)
@@ -69,8 +69,23 @@
   </div><?cs /if ?>
  </fieldset>
 
+ <?cs if newticket.workflow.template ?>
+  <fieldset id="action">
+   <legend>Action</legend>
+   <?cs include newticket.workflow.template ?>
+  </fieldset>
+ <?cs /if ?>
+
+ <?cs if newticket.workflow.error ?>
+   <div class="system-message">
+     <h2>Ticket Error</h2>
+     <p class="message"><?cs var newticket.workflow.error ?></p>
+     <strong>The ticket will not be created.</strong>
+   </div>
+ <?cs /if ?>
+
  <div class="buttons">
-  <input type="submit" value="Preview" />&nbsp;
+  <input type="submit" name="preview" value="Preview" />&nbsp;
   <input type="submit" name="create" value="Submit ticket" />
  </div>
 </form>
Index: templates/milestone.cs
===================================================================
--- templates/milestone.cs	(revision 1768)
+++ templates/milestone.cs	(working copy)
@@ -56,6 +56,11 @@
       var:milestone.name ?>" />
    </div>
    <div class="field">
+    <label for="owner">Owner of the milestone (Release Manager):</label><br />
+    <input type="text" id="owner" name="owner" size="32" value="<?cs
+      var:milestone.owner ?>" />
+   </div>
+   <div class="field">
     <label for="datemode">Completion date:</label><br />
     <select name="datemode" id="datemode"
         onchange="enableControl('date',this.value=='manual');
@@ -108,6 +113,7 @@
  <?cs else ?>
   <em class="date"><?cs if:milestone.date ?>
    <?cs var:milestone.date ?><?cs else ?>No date set<?cs /if ?>
+   <?cs if:milestone.owner ?>&nbsp;(<?cs var:milestone.owner ?>)<?cs /if ?>
   </em>
   <div class="descr"><?cs var:milestone.descr ?></div>
  <?cs /if ?>
@@ -120,7 +126,7 @@
   <thead><tr>
    <th class="name" rowspan="2"><?cs var:milestone.stats.grouped_by ?></th>
    <th class="tickets" scope="col" colspan="2">Tickets</th>
-   <th class="progress" rowspan="2">Percent Resolved</th>
+   <th class="progress" rowspan="2">Percent Completed</th>
   </tr><tr>
    <th class="open" scope="col">Active</th>
    <th class="closed" scope="col">Closed</th>
Index: templates/timeline.cs
===================================================================
--- templates/timeline.cs	(revision 1768)
+++ templates/timeline.cs	(working copy)
@@ -60,6 +60,13 @@
 
 <?cs each:item = timeline.items ?>
  <?cs call:day_separator(item.date) ?>
+ <?cs if:item.tdata && item.message ?>
+  <?cs set:ticketmsg = $item.tdata + ' - ' + $item.message ?>
+ <?cs elif:item.tdata ?>
+  <?cs set:ticketmsg = $item.tdata ?>
+ <?cs else ?>
+  <?cs set:ticketmsg = $item.message ?>
+ <?cs /if ?>
  <?cs if:item.type == #1 ?><!-- Changeset -->
   <?cs call:tlitem(item.href, 'changeset',
     'Changeset <em>['+$item.idata+']</em> by '+$item.author,$item.node_list+item.message) ?>
@@ -67,17 +74,20 @@
   <?cs call:tlitem(item.href, 'newticket',
     'Ticket <em>#'+$item.idata+'</em> created by '+$item.author, item.message) ?>
  <?cs elif:item.type == #3 ?><!-- Closed ticket -->
-  <?cs if:item.message ?>
-   <?cs set:imessage = ' - ' + $item.message ?>
-  <?cs else ?>
-   <?cs set:imessage = '' ?>
-  <?cs /if ?>
   <?cs call:tlitem(item.href, 'closedticket',
-    'Ticket <em>#'+$item.idata+'</em> resolved by '+$item.author, 
-    $item.tdata+$imessage) ?>
+    'Ticket <em>#'+$item.idata+'</em> closed by '+$item.author, $ticketmsg) ?>
  <?cs elif:item.type == #4 ?><!-- Reopened ticket -->
-  <?cs call:tlitem(item.href, 'newticket',
-    'Ticket <em>#'+$item.idata+'</em> reopened by '+$item.author, '') ?>
+  <?cs call:tlitem(item.href, 'reopenedticket',
+    'Ticket <em>#'+$item.idata+'</em> reopened by '+$item.author, item.message) ?>
+ <?cs elif:item.type == #7 ?><!-- Verified ticket -->
+  <?cs call:tlitem(item.href, 'closedticket',
+    'Ticket <em>#'+$item.idata+'</em> verified by '+$item.author, $ticketmsg) ?>
+ <?cs elif:item.type == #8 ?><!-- Resolved ticket -->
+  <?cs call:tlitem(item.href, 'resolvedticket',
+    'Ticket <em>#'+$item.idata+'</em> resolved by '+$item.author, $ticketmsg) ?>
+ <?cs elif:item.type == #9 ?><!-- Retested ticket -->
+  <?cs call:tlitem(item.href, 'reopenedticket',
+    'Ticket <em>#'+$item.idata+'</em> retested by '+$item.author, item.message) ?>
  <?cs elif:item.type == #5 ?><!-- Wiki change -->
   <?cs call:tlitem(item.href, 'wiki',
     '<em>'+$item.tdata+'</em> edited by '+$item.author, item.message) ?>

