"""Trac plugin that provides a number of advanced operations for customizable workflows. """ import os import time from subprocess import call from genshi.builder import tag from trac.core import implements, Component from trac.ticket import model from trac.ticket.api import ITicketActionController from trac.ticket.default_workflow import ConfigurableTicketWorkflow class TicketWorkflowOpBase(Component): """Abstract base class for 'simple' ticket workflow operations.""" implements(ITicketActionController) abstract = True _op_name = None # Must be specified. # ITicketActionController methods def get_ticket_actions(self, req, ticket): """Finds the actions that use this operation""" controller = ConfigurableTicketWorkflow(self.env) return controller.get_actions_by_operation_for_req(req, ticket, self._op_name) def get_all_status(self): """Provide any additional status values""" # We don't have anything special here; the statuses will be recognized # by the default controller. return [] # This should most likely be overridden to be more functional def render_ticket_action_control(self, req, ticket, action): """Returns the action control""" actions = ConfigurableTicketWorkflow(self.env).actions label = actions[action]['name'] return (label, tag(''), '') def get_ticket_changes(self, req, ticket, action): """Must be implemented in subclasses""" raise NotImplementedError def apply_action_side_effects(self, req, ticket, action): """No side effects""" pass class TicketWorkflowOpOwnerReporter(TicketWorkflowOpBase): """Sets the owner to the reporter of the ticket. needinfo = * -> needinfo needinfo.name = Need info needinfo.operations = set_owner_to_reporter Don't forget to add the `TicketWorkflowOpOwnerReporter` to the workflow option in [ticket]. If there is no workflow option, the line will look like this: workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerReporter """ _op_name = 'set_owner_to_reporter' # ITicketActionController methods def render_ticket_action_control(self, req, ticket, action): """Returns the action control""" actions = ConfigurableTicketWorkflow(self.env).actions label = actions[action]['name'] hint = 'The owner will change to %s' % ticket['reporter'] control = tag('') return (label, control, hint) def get_ticket_changes(self, req, ticket, action): """Returns the change of owner.""" return {'owner': ticket['reporter']} class TicketWorkflowOpOwnerComponent(TicketWorkflowOpBase): """Sets the owner to the default owner for the component. .operations = set_owner_to_component_owner Don't forget to add the `TicketWorkflowOpOwnerComponent` to the workflow option in [ticket]. If there is no workflow option, the line will look like this: workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerComponent """ _op_name = 'set_owner_to_component_owner' # ITicketActionController methods def render_ticket_action_control(self, req, ticket, action): """Returns the action control""" actions = ConfigurableTicketWorkflow(self.env).actions label = actions[action]['name'] hint = 'The owner will change to %s' % self._new_owner(ticket) control = tag('') return (label, control, hint) def get_ticket_changes(self, req, ticket, action): """Returns the change of owner.""" return {'owner': self._new_owner(ticket)} def _new_owner(self, ticket): """Determines the new owner""" component = model.Component(self.env, name=ticket['component']) self.env.log.debug("component %s, owner %s" % (component, component.owner)) return component.owner class TicketWorkflowOpOwnerField(TicketWorkflowOpBase): """Sets the owner to the value of a ticket field .operations = set_owner_to_field .set_owner_to_field = myfield Don't forget to add the `TicketWorkflowOpOwnerField` to the workflow option in [ticket]. If there is no workflow option, the line will look like this: workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerField """ _op_name = 'set_owner_to_field' # ITicketActionController methods def render_ticket_action_control(self, req, ticket, action): """Returns the action control""" actions = ConfigurableTicketWorkflow(self.env).actions label = actions[action]['name'] hint = 'The owner will change to %s' % self._new_owner(action, ticket) control = tag('') return (label, control, hint) def get_ticket_changes(self, req, ticket, action): """Returns the change of owner.""" return {'owner': self._new_owner(action, ticket)} def _new_owner(self, action, ticket): """Determines the new owner""" # Should probably do some sanity checking... field = self.config.get('ticket-workflow', action + '.' + self._op_name).strip() return ticket[field] class TicketWorkflowOpOwnerPrevious(TicketWorkflowOpBase): """Sets the owner to the previous owner Don't forget to add the `TicketWorkflowOpOwnerPrevious` to the workflow option in [ticket]. If there is no workflow option, the line will look like this: workflow = ConfigurableTicketWorkflow,TicketWorkflowOpOwnerPrevious """ _op_name = 'set_owner_to_previous' # ITicketActionController methods def render_ticket_action_control(self, req, ticket, action): """Returns the action control""" actions = ConfigurableTicketWorkflow(self.env).actions label = actions[action]['name'] new_owner = self._new_owner(ticket) if new_owner: hint = 'The owner will change to %s' % new_owner else: hint = 'The owner will be deleted.' control = tag('') return (label, control, hint) def get_ticket_changes(self, req, ticket, action): """Returns the change of owner.""" return {'owner': self._new_owner(ticket)} def _new_owner(self, ticket): """Determines the new owner""" db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \ "AND field='owner' ORDER BY -time", (ticket.id, )) row = cursor.fetchone() if row: owner = row[0] else: # The owner has never changed. owner = '' return owner class TicketWorkflowOpStatusPrevious(TicketWorkflowOpBase): """Sets the status to the previous status Don't forget to add the `TicketWorkflowOpStatusPrevious` to the workflow option in [ticket]. If there is no workflow option, the line will look like this: workflow = ConfigurableTicketWorkflow,TicketWorkflowOpStatusPrevious """ _op_name = 'set_status_to_previous' # ITicketActionController methods def render_ticket_action_control(self, req, ticket, action): """Returns the action control""" actions = ConfigurableTicketWorkflow(self.env).actions label = actions[action]['name'] new_status = self._new_status(ticket) if new_status != ticket['status']: hint = 'The status will change to %s' % new_status else: hint = '' control = tag('') return (label, control, hint) def get_ticket_changes(self, req, ticket, action): """Returns the change of status.""" return {'status': self._new_status(ticket)} def _new_status(self, ticket): """Determines the new status""" db = self.env.get_db_cnx() cursor = db.cursor() cursor.execute("SELECT oldvalue FROM ticket_change WHERE ticket=%s " \ "AND field='status' ORDER BY -time", (ticket.id, )) row = cursor.fetchone() if row: status = row[0] else: # The status has never changed. status = 'new' return status class TicketWorkflowOpRunExternal(Component): """Action to allow running an external command as a side-effect. If it is a lengthy task, it should daemonize so the webserver can get back to doing its thing. If the script exits with a non-zero return code, an error will be logged to the Trac log. The plugin will look for a script named /hooks/, and will pass it 2 parameters: the ticket number, and the user. .operations = run_external .run_external = Hint for the user Don't forget to add the `TicketWorkflowOpRunExternal` to the workflow option in [ticket]. If there is no workflow option, the line will look like this: workflow = ConfigurableTicketWorkflow,TicketWorkflowOpRunExternal """ implements(ITicketActionController) # ITicketActionController methods def get_ticket_actions(self, req, ticket): """Finds the actions that use this operation""" controller = ConfigurableTicketWorkflow(self.env) return controller.get_actions_by_operation_for_req(req, ticket, 'run_external') def get_all_status(self): """Provide any additional status values""" # We don't have anything special here; the statuses will be recognized # by the default controller. return [] def render_ticket_action_control(self, req, ticket, action): """Returns the action control""" actions = ConfigurableTicketWorkflow(self.env).actions label = actions[action]['name'] hint = self.config.get('ticket-workflow', action + '.run_external').strip() if hint is None: hint = "Will run external script." return (label, tag(''), hint) def get_ticket_changes(self, req, ticket, action): """No changes to the ticket""" return {} def apply_action_side_effects(self, req, ticket, action): """Run the external script""" script = os.path.join(self.env.path, 'hooks', action) retval = call([script, str(ticket.id), req.authname]) if retval: self.env.log.error("External script %r exited with return code %s." % (script, retval)) class TicketWorkflowOpTriage(TicketWorkflowOpBase): """Action to split a workflow based on a field = somestatus -> * .operations = triage .triage_field = type .traige_split = defect -> new_defect, task -> new_task, enhancement -> new_enhancement Don't forget to add the `TicketWorkflowOpTriage` to the workflow option in [ticket]. If there is no workflow option, the line will look like this: workflow = ConfigurableTicketWorkflow,TicketWorkflowOpTriage """ _op_name = 'triage' # ITicketActionController methods def render_ticket_action_control(self, req, ticket, action): """Returns the action control""" actions = ConfigurableTicketWorkflow(self.env).actions label = actions[action]['name'] new_status = self._new_status(ticket, action) if new_status != ticket['status']: hint = 'The status will change.' else: hint = '' control = tag('') return (label, control, hint) def get_ticket_changes(self, req, ticket, action): """Returns the change of status.""" return {'status': self._new_status(ticket, action)} def _new_status(self, ticket, action): """Determines the new status""" field = self.config.get('ticket-workflow', action + '.triage_field').strip() transitions = self.config.get('ticket-workflow', action + '.triage_split').strip() for transition in [x.strip() for x in transitions.split(',')]: value, status = [y.strip() for y in transition.split('->')] if value == ticket[field].strip(): break else: self.env.log.error("Bad configuration for 'triage' operation in action '%s'" % action) status = 'new' return status class TicketWorkflowOpXRef(TicketWorkflowOpBase): """Adds a cross reference to another ticket .operations = xref .xref = "Ticket %s is related to this ticket" .xref_local = "Ticket %s was marked as related to this ticket" .xref_hint = "The specified ticket will be cross-referenced with this ticket" The example values shown are the default values. Don't forget to add the `TicketWorkflowOpXRef` to the workflow option in [ticket]. If there is no workflow option, the line will look like this: workflow = ConfigurableTicketWorkflow,TicketWorkflowOpXRef """ _op_name = 'xref' # ITicketActionController methods def render_ticket_action_control(self, req, ticket, action): """Returns the action control""" id = 'action_%s_xref' % action ticketnum = req.args.get(id, '') actions = ConfigurableTicketWorkflow(self.env).actions label = actions[action]['name'] hint = actions[action].get('xref_hint', 'The specified ticket will be cross-referenced with this ticket') control = tag.input(type='text', id=id, name=id, value=ticketnum) return (label, control, hint) def get_ticket_changes(self, req, ticket, action): """Returns no changes.""" return {} def apply_action_side_effects(self, req, ticket, action): """Add a cross-reference comment to the other ticket""" # TODO: This needs a lot more error checking. id = 'action_%s_xref' % action ticketnum = req.args.get(id).strip('#') actions = ConfigurableTicketWorkflow(self.env).actions author = req.authname # Add a comment to the "remote" ticket to indicate this ticket is # related to it. format_string = actions[action].get('xref', 'Ticket %s is related to this ticket') comment = format_string % ('#%s' % ticket.id) # FIXME: This assumes the referenced ticket exists. xticket = model.Ticket(self.env, ticketnum) # FIXME: We _assume_ we have sufficient permissions to comment on the # other ticket. xticket.save_changes(author, comment) # Add a comment to this ticket to indicate that the "remote" ticket is # related to it. (But only if .xref_local was set in the # config.) format_string = actions[action].get('xref_local', 'Ticket %s was marked as related to this ticket') if format_string: comment = format_string % ('#%s' % ticketnum) time.sleep(1) # FIXME: Hack around IntegrityError # HACK: Grab a new ticket object to avoid getting # "OperationalError: no such column: new" xticket = model.Ticket(self.env, ticket.id) xticket.save_changes(author, comment)