Groovy based JIRA Workflow Post Functions

18 Jun, 2017 | 7 minutes read

As in my previous Atlassian related article, I will stress from the very beginning that we are not going to develop anything revolutionary or something that does not exist as a plugin on the Atlassian Market. On the other side, the article will demonstrate how easy is to develop and implement a JIRA plugin that will support the definition of Workflow Post Functions based on Dynamic Groovy Script specified in the JIRA user interface.

JIRA Workflows can be customized in various ways by defining new Steps and Transitions. For each Transition, we can specify Screen, Conditions, Validators, and Post Functions. In this article, we are particularly interested in the Post Functions that allow us to add additional logic during the transition from one state to another. For example:

  • On Issue creation automatically add a comment that will mention all users associated with the JIRA project’s role Approvers (this way they will receive mail notification)
  • Create automatically Sub-Tasks once the Task has been approved.

We are going to develop a JIRA plugin that will allow us to specify JIRA Workflow Post Functions that will execute custom and dynamic Groovy scripts specified by the JIRA administrator.

Groovy is a powerful dynamic language for the Java Virtual Machine (remember Grails?).

Someone can very easily embed externalized, scripted logic based on Groovy into their Maven-based Java application by including the following dependency:

<dependency>
    <groupId>org.codehaus.groovy</groupId>
    <artifactId>groovy-all</artifactId>
    <version>2.4.7</version>
</dependency>

Let’s assume that we have the following Groovy toy script:

script.groovy

import java.lang.Math
def result=a+b
println result
l=Math.log10(a)

The code that will execute the above script is as simple as:

RunGroovyScript.java

import java.io.File;

import groovy.lang.Binding;
import groovy.lang.GroovyShell;
public class RunGroovyScript {
    public static void main(String[] args) throws Throwable {
        Binding binding = new Binding();
        binding.setProperty("a", 100);
        binding.setProperty("b", 200);
        GroovyShell shell = new GroovyShell(RunGroovyScript.class.getClassLoader(), binding);
        shell.evaluate(new File("script.groovy"));
        System.out.println(binding.getVariables());
    }
}

Note that the binding contains variable l, but not the variable result. The binding rule is very simple: the variables in the Groovy script specified with the def keyword are NOT available in the binding objects after the script execution.

Pretty simple, isn’t it?

So, let’s implement the same technique in the context of the JIRA plugin. The idea is to bind the current issue and make it available in dynamic Groovy script executed as JIRA Workflow Post Function.

Execute the command atlas-create-jira-plugin and answer the wizard questions. Navigate into the created plugin’s folder and run the module creation command:

atlas-create-jira-plugin-module

We are going to create a Post Function, so choose option 33 in the wizard. I’ve chosen the name GroovyScriptFunction which generates the following definition in the file atlassian-plugin.xml:

<workflow-function key="groovy-script-function" name="Groovy Script Function" i18n-name-key="groovy-script-function.name" class="com.interworks.jira.plugins.jira.workflow.GroovyScriptFunctionFactory">
    <description key="groovy-script-function.description">The Groovy Script Function Plugin</description>
    <function-class>com.interworks.jira.plugins.jira.workflow.GroovyScriptFunction</function-class>
    <resource type="velocity" name="view" location="templates/postfunctions/groovy-script-function.vm"/>
    <resource type="velocity" name="input-parameters" location="templates/postfunctions/groovy-script-function-input.vm"/>
    <resource type="velocity" name="edit-parameters" location="templates/postfunctions/groovy-script-function-input.vm"/>
  </workflow-function>

The best in the whole scenario is that the generated skeleton code provides almost all that we need for the plugin. In order to define and run Groovy scripts, we need only one HTML Text Area in the user interface that will accept the script definition. The velocity templates that define the UI part of the plugin are:

groovy-script-function-input.vm

<div>
    <u>Groovy Script</u>
</div>
<div>
     <textarea rows="20" cols="120" id="groovyScript" name="groovyScript">$groovyScript</textarea>
</div>

groovy-script-function.vm


<div>
    <u>Groovy Script</u>
</div>
<div>
     <pre>$groovyScript</pre>
</div>

The Java code is also very, very simple and self-explanatory. The post-function factory that deals with UI parameters collection is:

GroovyScriptFunctionFactory.java

package com.interworks.jira.plugins.jira.workflow;

import java.util.HashMap;
import java.util.Map;

import com.atlassian.jira.plugin.workflow.AbstractWorkflowPluginFactory;
import com.atlassian.jira.plugin.workflow.WorkflowPluginFunctionFactory;
import com.opensymphony.workflow.loader.AbstractDescriptor;
import com.opensymphony.workflow.loader.FunctionDescriptor;

public class GroovyScriptFunctionFactory extends AbstractWorkflowPluginFactory
        implements WorkflowPluginFunctionFactory {

    public static final String GROOVY_SCRIPT = "groovyScript";

    public static final String DEFAULT_SCRIPT = "//InterWorks Tip: Enter Groovy Script Here";

    public GroovyScriptFunctionFactory() {

    }

    @Override
    protected void getVelocityParamsForInput(Map<String, Object> velocityParams) {
        velocityParams.put(GROOVY_SCRIPT, DEFAULT_SCRIPT);
    }

    @Override
    protected void getVelocityParamsForEdit(Map<String, Object> velocityParams, AbstractDescriptor descriptor) {
        getVelocityParamsForInput(velocityParams);
        getVelocityParamsForView(velocityParams, descriptor);
    }

    @Override
    protected void getVelocityParamsForView(Map<String, Object> velocityParams, AbstractDescriptor descriptor) {
        if (!(descriptor instanceof FunctionDescriptor)) {
            throw new IllegalArgumentException("Descriptor must be a FunctionDescriptor.");
        }

        FunctionDescriptor functionDescriptor = (FunctionDescriptor) descriptor;
        String script = (String) functionDescriptor.getArgs().get(GROOVY_SCRIPT);

        if (script == null) {
            script = DEFAULT_SCRIPT;
        }

        velocityParams.put(GROOVY_SCRIPT, script);
    }

    public Map<String, ?> getDescriptorParams(Map<String, Object> formParams) {
        Map<String, Object> params = new HashMap<String, Object>();

        String script = extractSingleParam(formParams, GROOVY_SCRIPT);
        if (script == null)
            script = DEFAULT_SCRIPT;
        params.put(GROOVY_SCRIPT, script);

        return params;
    }
}

The factory code contains parameter mapping only.

Finally, the post function itself is defined into several lines:

GroovyScriptFunction.java

package com.interworks.jira.plugins.jira.workflow;

import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.atlassian.jira.issue.MutableIssue;
import com.atlassian.jira.workflow.function.issue.AbstractJiraFunctionProvider;
import com.opensymphony.module.propertyset.PropertySet;
import com.opensymphony.workflow.WorkflowException;

import groovy.lang.Binding;
import groovy.lang.GroovyShell;

public class GroovyScriptFunction extends AbstractJiraFunctionProvider {

    private static final Logger log = LoggerFactory.getLogger(GroovyScriptFunction.class);

    public static final String GROOVY_SCRIPT = "groovyScript";

    @SuppressWarnings("rawtypes")
    public void execute(Map transientVars, Map args, PropertySet ps) throws WorkflowException {
        log.debug("Groovy Script execute start...");
        MutableIssue issue = getIssue(transientVars);
        String script = (String) args.get(GROOVY_SCRIPT);
        Binding binding = new Binding();
        binding.setProperty("issue", issue);
        GroovyShell shell = new GroovyShell(this.getClass().getClassLoader(), binding);
        shell.evaluate(script);
        log.debug("Groovy Script execute end.");
    }
}

There is nothing special in the method execute(). We are getting the current Issue and the Groovy Script text. The issue is exposed in the Groovy binding under the name issue, i.e. the Groovy script will have access to the issue variable.

One thing that I’ve found tricky is class loading and the available Java packages in the plugin code. The JIRA plugin system is OSGI-based (Apache Felix) and the plugin’s OSGI dependencies must be specified in the plugin’s JAR manifest. For Java-only compiled code-based plugins, there is no need for any configuration because the maven-jira-plugin will scan the plugin classes and automatically add any dependencies in the manifest file. However, our plugin will execute Groovy scripts that will reference classes not known at the compile time. I’ve assumed that our Groovy code will execute code related to JIRA Issues, Comments, etc. For that purpose, in Maven’s pom.xml I’ve defined the OSGI packages to be imported as:

<Import-Package>
com.atlassian.jira.component,
com.atlassian.jira.issue,
com.atlassian.jira.issue.comments,
com.atlassian.jira.issue.index,
com.atlassian.jira.project,
com.atlassian.jira.security.roles,
com.atlassian.jira.bc.projectroles,
com.atlassian.jira.user,
com.atlassian.jira.util,
com.atlassian.jira.config,
com.atlassian.jira.workflow.function.issue,
*;resolution:="optional"
</Import-Package>

Probably you will have to add additional packages here depending on what you are trying to script.

At this stage, we are ready to try the plugin. Start JIRA with the command:

atlas-run

In order to test the auto-comment scenario specified above, you should create:

  • Project (TEST) of type Project Management
  • System-wide role Approvers
  • Users: Alice, Bob and Trudy
  • Add users Alice and Bob to the Approvers role in the TEST project

Finally, we shall modify the TEST project’s associated workflow and define Post Function on the Create transition (click on that transition in the Diagram view). Once the transition has been selected, click the Post Functions link on the right of the screen and Add post function link after that. Choose the “Groovy Script Function” option and enter the following script into the provided text area:

import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.util.SimpleErrorCollection
import com.atlassian.jira.user.ApplicationUser
import com.atlassian.jira.security.roles.ProjectRoleManager
import com.atlassian.jira.bc.projectroles.ProjectRoleService

def currentUser = ComponentAccessor.jiraAuthenticationContext.user

def commentManager = ComponentAccessor.commentManager

if(issue){
    def projectRoleService = ComponentAccessor.getComponentOfType(ProjectRoleService.class)
    def projectRoleManager = ComponentAccessor.getComponentOfType(ProjectRoleManager.class)
    def projectManager = ComponentAccessor.projectManager;

    def project = projectManager.getProjectObj(issue.projectId);
    def approvers = projectRoleManager.getProjectRole("Approvers");

    def errorCollection = new SimpleErrorCollection();
    def existingActors = projectRoleService.getProjectRoleActors(approvers,project,errorCollection);
    if (existingActors != null){
        def users=existingActors.users
        def    comment=new StringBuilder()
        for(ApplicationUser user:users){
            def username=user.username;
            if(comment.length()>0)
                comment.append(" or ")
            comment.append("[~").append(username).append("]")
            
        }
        comment.append(": Please approve or deny the task")
        commentManager.create(issue, currentUser,comment.toString(), true)
    }
}

In essence, this script:

  • Gets issue’s project
  • Gets the project’s approvers, i.e. collection of users assigned to the project’s role Approvers
  • Iterates through the approvers and builds comment string
  • Adds the comment to the issue.

In our case the comment string will be:

[~alice] or [~bob]: Please approve or deny the task

The syntax [~username] is known as JIRA mention. It will trigger notification to the referenced user.

Note: Move the Post Function created at the bottom of the list of existing Post Functions.

Publish the modified JIRA workflow and create new Issue in the TEST project. Notice the comment created automatically!

How about automatic creation of Sub-tasks after some transition? Here is a sample Groovy script that will do that if specified as a transition Post Function:

import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.index.IssueIndexingService
import com.atlassian.jira.util.ImportUtils

def createSubTask(issue,summary) {
    def issueFactory=ComponentAccessor.issueFactory
    def issueManager =ComponentAccessor.issueManager
    def subTaskManager=ComponentAccessor.subTaskManager
    
    def subTaskObject=issueFactory.issue
    subTaskObject.setProjectId(issue.projectId)
    subTaskObject.setIssueTypeId("10001")
    subTaskObject.setParentId(issue.id)
    subTaskObject.setPriority(issue.priority)
    subTaskObject.setSummary(summary)
    subTaskObject.setDescription("Short description:"+summary)
    if(issue.assignee!=null)
        subTaskObject.setAssignee(issue.assignee)

    def currentUser = ComponentAccessor.jiraAuthenticationContext.user

    def subTask = issueManager.createIssueObject(currentUser, subTaskObject)
    subTaskManager.createSubTaskIssueLink(issue, subTask, currentUser)
    def issueIndexService= (IssueIndexingService)ComponentAccessor.getComponent(IssueIndexingService.class)
    issueIndexService.reIndex(subTask)
}

createSubTask(issue, "Execute Task A")
createSubTask(issue,"Execute Task B")

Let’s summarize what we have done:

  • We have built a Groovy script based JIRA plugin with very little effort
  • We have provided 2 simple and short Groovy scripts that accomplish very common scenarios if you need customized JIRA Workflows (who doesn’t :-)?)

Till next time!