Writing Trac workflow plugins.

"Trac" is not a spelling mistake. It is indeed "Trac" and not "Track". But this "Trac" is not referring to rail tracks, running tracks or track suits. This is a software and it is used for issue tracking. Everything written in this document applies to Trac version 0.12.2. It may or may not apply to other Trac versions. This writeup is for people who are already using Trac, trying to do what Trac does not do out-of-the-box, and finding it difficult :-) The functionality of Trac can be extended by writing custom plugins. The indispensable guide for every Trac plugin writer is the document called "Writing Plugins for Trac". There is another reference type guide which might be useful as well. Please make sure you have gone through these before continuing. First we will briefly touch some basic Trac concepts that we need to be very clear about before writing workflow plugins.

Actions, states, and operations

States

"State" refers to ... err ... the current state of a Trac ticket. When a ticket is opened, it is in "new" state. When it is closed, it is in "closed" state. In between these, there can be other states like "assigned", "procrastinating", "pretending to be fixing", "actually fixing", "under test with inspector Gadget", "failed testing :-(" etc. etc. The actual list of possible states are configurable and vary from site to site. But you have got the idea.

Action

The primary purpose of an action is to change the state of a ticket. So we can have an action called "start" which will change the status of the ticket to (say) "fixing". Internally, Trac maintains every ticket as an object (as in object oriented programming). Quite naturally, objects have properties (e.g. ticket['status']). The task of the action is to modify the ticket object. The most common modification is to change the status of the ticket:
      ticket['status'] == new
                  |
                  |  assign_action
                  \/
      ticket['status'] == assigned
                  |
                  |  kill_action
                  \/
      ticket['status'] == closed
The actual mapping between actions and states is configurable and best explained through an example configuration snippet:
starting = assigned -> fixing
test     = fixing   -> under_test
Here "starting" and "test" are actions. The "starting" action is available (in the Trac interface) only when the ticket is in the "assigned" state. Selecting this action changes the state of the ticket to "fixing".

Operations

Operations are the right-hand man of actions. Actions do not do anything by themselves, they just tell their operations to do something (for example, to change the ticket owner) and the operations just do it. By default, almost all actions have one operation assigned to them and that is the state change operation. Other operations are assigned to actions from Trac's configuration file. Here is an extended version of the previous example:
starting           = assigned -> fixing
starting.operation = set_owner
test               = fixing   -> under_test
test.operation     = chase_cats
With a configuration like this, the "starting" action will do two things:
  1. Change the state of the ticket from "assigned" to "fixing" state. This is because of the default operation.
  2. Change the owner of the ticket. This is due to the "set_owner" operation.
Operations are reusable, i.e. a single operation can be assigned to different actions. Trac comes with a few built-in operations like set_owner, set_resolution, etc. If these are not enough, then we need to write custom plugins that support one or more custom operations. But we will not touch custom operations here.

Plugins

As all Trac plugin writers should know, Trac plugins are classes that implement an interface from a predefined set of interfaces. If a plugin has anything to do with actions, it should implement the ITicketActionController interface. This interface requires us to implement five methods:
ITicketActionController
get_ticket_actions() - It returns all actions this plugin wants to work on.
get_all_status() - I am unsure about this one :-(
render_ticket_action_control() - Tells Trac how a supported action should be rendered.
get_ticket_changes() - Tells Trac what property of the ticket to change when a supported action has been selected.
apply_action_side_effects() - Executes any post-action operations like email sending.
The most important of these methods is get_ticket_actions(). This method decides when to make an action available for which ticket. For example, we may want to write a plugin to make the "eat_cheese" action available on any ticket that is owned by "Jerry". The code will be like this:
  if ticket_object['owner'] == 'Jerry' :
      action = [(0, 'eat_cheese')]
  else
      action = []

  return action

Workflow plugin

Usually, we create actions and states in the "ticket-workflow" section of trac.ini. If we have something like:
  eat_cheese : tom_not_around -> eaten
- then the "eat_cheese" action will be available every time the ticket is in the "tom_not_around" state. But if we want to impose some extra conditions on the availability of the "eat_cheese" action, then there is no way to do it in Trac out-of-the-box. But we can do it through a custom plugin. With a custom get_ticket_actions() method, we can easily return the "eat_cheese" action every time the given ticket is in "tom_not_around" state. But we want to do more. We only want to make the "eat_cheese" action available when "Tom" is NOT the owner of the ticket. To do that, we will need extra checks inside our get_ticket_actions() method:
  if ticket_object['owner'] != 'Tom' :
      return actions
  else :
      return []
Finally, here is the full plugin that makes the "eat_cheese" action available only when Tom is NOT the owner of the ticket and the ticket is in the "tom_not_around" state:
from trac.core import Component, implements
from trac.ticket.api import ITicketActionController

from genshi.builder import tag


class eatCheeseAction (Component) :
    """
    Trac action plugin.  It provides the 'eat_cheese' action when
    the owner of the given ticket is NOT 'Tom' and the ticket
    IS in the 'tom_not_around' state.  If this action is selected,
    the status of the ticket will change to 'eaten'.
    """

    implements (ITicketActionController)


    def get_ticket_actions(self, req, ticket) :
        """
        Returns the list of supported actions for the given ticket object.
        """

        if 'Tom' != ticket['owner'] and 'tom_not_around' == ticket['status'] :
            return [(0, 'eat_cheese')]
        else :
            return []


    def get_all_status(self) :
        """
        This should return a list of all known states.  Returning an empty
        list will do for our purposes.
        """

        return []


    def render_ticket_action_control(self, req, ticket, action) :
        """
        Returns a tuple that is used to product the HTML code for the action's
        display.
        """

        return ('Eat cheese', tag(''), '')


    def get_ticket_changes(self, req, ticket, action) :
        """
        Return the properties of the ticket object that should be changed.
        Also return the new values of the properties.  For example, if the
        value of the 'foo' and 'bar' properties of the ticket object should
        be changed to 'zero' and 'one' respectively, then return
        {'foo' : 'zero', 'bar' : 'one'}.
        """

        # The status of the ticket should be changed to 'eaten'.
        return {'status' : 'eaten'}


    def apply_action_side_effects(self, req, ticket, action) :
        """
        Execute any operation (e.g. sending of an email) that should happen
        after a supported action has been selected.  If there is no such
        operation, the just call 'pass'.
        """

        pass
Now there is one more step left. We need to include the plugin's name in the "workflow" list in trac.ini. This is only required for workflow plugins. The following should do:
[ticket]
...
...
...
workflow = ConfigurableTicketWorkflow, eatCheeseAction

Final notes

I will finish with a few important notes:
  • Trac comes with a few sample plugins the help people learn how to write plugins. These are inside the "sample-plugins" directory of Trac's source tarball. These are worth looking at.
  • There are a few sample workflow plugins inside the "TRAC-SOURCE-DIRECTORY/sample-plugins/workflow" directory. These were of great help to me.
  • Trac comes with a built-in plugin called default_workflow. Its full name is "trac.ticket.api.default_workflow". Its source code is a good example for anyone trying to write workflow plugins.
  • If an action is mentioned inside the "[ticket-workflow]" section of trac.ini, then it is not possible to control its appearance from custom workflow plugins. Trac's built-in default_workflow plugin will bring it up in the interface irrespective of what the custom plugin decides. So if you want to control the display of an action, do not mention it in the "[ticket-workflow]" section.