How to create JIRA Spring Scanner plugin from scratch


JIRA Spring Scanner plugin

In this article I will show how to create a JIRA add-on using modern Atlassian Spring Scanner approach. I assume that you already have Atlassian Plugin SDK installed the version at least 6.2.9 and configured so, that its commands are available in the terminal. Java 8 or higher is required as well.

  1. Create plugin
    1. Define a goal
    2. Generate plugin skeleton
    3. Create service interface
    4. Create service implementation
    5. Create action
    6. Add action mapping
    7. Add action Velocity template
  2. Create test
    1. Create wired test
  3. Test manually
  4. Run automated tests

So, the first thing to be done is defining of the desired solution. JIRA is a task management system, so it has a lot of tasks — hundreds or even thousands of them. That’s fine until someone starts assigning tasks to you and demands to resolve them as soon as possible. In this article, we’ll create a magic helper plugin that will resolve tasks for us. It will look like a form on the JIRA page where one will be able to enter arbitrary task key and resolve it by pressing the button.

So let’s get down. First, generate an empty plugin skeleton using Atlassian Plugin SDK. Later we’ll fill it with useful logic.

atlas-create-jira-plugin
...
Define value for groupId: : com.rozdoum.resolver
Define value for artifactId: : task-resolver
Define value for version:  1.0.0-SNAPSHOT: : 
Define value for package:  com.rozdoum: :
...
BUILD SUCCESS

This command launches a skeleton creation wizard that asks for group id, artifact id, and a version for Maven project. For this sample enter com.rozdoum.resolver as group id, task-resolver as artifact id and leave a version empty default value 1.0.0-SNAPSHOT will be used. Once necessary data is entered, SDK generates sample Maven project with desired directory structure, Java and Javascript sources and bunch of configuration files to start with.

Like many other enterprise systems, JIRA uses MVC pattern to organize request handling. According to MVC, all the business logic has to be stored in the separate Services. Atlassian suggests separating service interface from implementation. Directory structure generated by SDK contains separated packages for interface classes and implementation classes. For this guide mentioned packages are com.rozdoum.resolver.api and com.rozdoum.resolver.impl. Let’s create an interface for TaskResolver service in the com.rozdoum.resolver.api package.

package com.rozdoum.resolver.api;


public interface TaskResolver {


   void resolve(String issueKey);
}

The interface of the service contains just one method that gets issue key as a parameter. Here is its implementation. It should be placed into com.rozdoum.resolver.impl package for service implementations.

package com.rozdoum.resolver.impl;


import com.atlassian.jira.bc.issue.IssueService;
import com.atlassian.jira.bc.issue.IssueService.TransitionValidationResult;
import com.atlassian.jira.config.StatusManager;
import com.atlassian.jira.issue.Issue;
import com.atlassian.jira.issue.IssueInputParameters;
import com.atlassian.jira.issue.IssueManager;
import com.atlassian.jira.issue.status.Status;
import com.atlassian.jira.security.JiraAuthenticationContext;
import com.atlassian.jira.user.ApplicationUser;
import com.atlassian.jira.workflow.JiraWorkflow;
import com.atlassian.jira.workflow.WorkflowManager;
import com.atlassian.plugin.spring.scanner.annotation.export.ExportAsService;
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;
import com.opensymphony.workflow.loader.ActionDescriptor;
import com.opensymphony.workflow.loader.StepDescriptor;
import com.rozdoum.resolver.api.TaskResolver;


import javax.inject.Inject;
import javax.inject.Named;
import java.util.Collection;
import java.util.List;


@ExportAsService({TaskResolver.class})
@Named
public class TaskResolverImpl implements TaskResolver {


   public static final String RESOLVED_STATUS_NAME = "Resolved";


   private final IssueManager issueManager;
   private final IssueService issueService;
   private final WorkflowManager workflowManager;
   private final StatusManager statusManager;
   private final JiraAuthenticationContext jiraApplicationContext;


   @Inject
   public TaskResolverImpl(@ComponentImport IssueManager issueManager,
                           @ComponentImport IssueService issueService,
                           @ComponentImport WorkflowManager workflowManager,
                           @ComponentImport StatusManager statusManager,
                           @ComponentImport JiraAuthenticationContext jiraAuthenticationContext) {
       this.issueManager = issueManager;
       this.issueService = issueService;
       this.workflowManager = workflowManager;
       this.statusManager = statusManager;
       this.jiraApplicationContext = jiraAuthenticationContext;
   }


   public void resolve(String issueKey) {
       Issue issue = issueManager.getIssueObject(issueKey);
       if (issue != null) {
           Status currentStatus = issue.getStatus();
           Status resolvedStatus = findStatus(RESOLVED_STATUS_NAME);
           if (!currentStatus.equals(resolvedStatus)) {
               JiraWorkflow workflow = workflowManager.getWorkflow(issue);
               StepDescriptor resolvedStatusStep = workflow.getLinkedStep(resolvedStatus);
               Collection resolveActions = workflow.getActionsWithResult(resolvedStatusStep);
               StepDescriptor currentStatusStep = workflow.getLinkedStep(currentStatus);
               List currentActions = currentStatusStep.getActions();
               resolveActions.retainAll(currentActions);
               ActionDescriptor resolveAction = resolveActions.iterator().next();


               ApplicationUser currentUser = jiraApplicationContext.getLoggedInUser();
               Long issueId = issue.getId();
               int resolveActionId = resolveAction.getId();
               IssueInputParameters issueInputParameters = issueService.newIssueInputParameters();
               TransitionValidationResult transitionValidationResult = issueService.validateTransition(currentUser,
                       issueId, resolveActionId, issueInputParameters);
               issueService.transition(currentUser, transitionValidationResult);
           }
       }
   }


   private Status findStatus(String statusName) {
       Collection statuses = statusManager.getStatuses();
       Status foundStatus = null;
       for (Status status : statuses) {
           if (status.getName().equals(statusName)) {
               foundStatus = status;
               break;
           }
       }


       return foundStatus;
   }
}

Pay attention to the annotations here. They are responsible for recognizing of this class as valid JIRA component that can be injected into other components on demand.

  • @Named annotation marks this class as JIRA component (internally it is a regular Spring bean. You can even use familiar @Component annotation). Optionally name of the bean may be specified.

  • @ExportAsService annotation marks a component as a public OSGi service that can be imported into other plugins and used as API to your plugin. It is also needed for components which are supposed to be used in the wired tests since tests are packaged as a separate JIRA plugin. Passed class argument point to the interface that also can be used for injection of the current component.

  • @Inject annotation marks a constructor which will be called by DI container (Spring) on bean instantiation phase. All of the constructor arguments are considered as injectable components. Appropriate implementations or explicit classes will be found on runtime in DI context and injected into the constructor.

  • @ComponentImport annotation is needed for Spring scanner runtime to generate OSGi imports of the public services which are living in other bundles. By the way, JIRA is built using OSGi framework to make possible to load and unload plugins without restarting JIRA each time. As a consequence, every JIRA plugin is wrapped by OSGi bundle during the installation. A regular developer has to keep it in mind because OSGi restricts some functionality. In the case of TaskResolverImpl class, all of the components that are injected into constructor live in JIRA system bundle. Although they are native JIRA citizens, they have to be imported as any external service from another OSGi bundle.

Now we have got a working component that resolves issues by key. At the next step, we’ll create some UI that would allow a user to reach new functions. JIRA uses Webwork framework to handle HTTP requests. The core entity of the Webwork is actions. They serve as Controllers in the scope of MVC. Here is the action that gets issue key from the request and passes it to the TaskResolver. Place it in com.rozdoum.resolver.action package.

package com.rozdoum.resolver.action;


import com.atlassian.jira.web.action.JiraWebActionSupport;
import com.rozdoum.resolver.api.TaskResolver;
import org.apache.commons.lang.StringUtils;


public class TaskResolverAction extends JiraWebActionSupport {


   private final TaskResolver taskResolver;


   private String issueKey;


   public TaskResolverAction(TaskResolver taskResolver) {
       this.taskResolver = taskResolver;
   }


   public void setIssueKey(String issueKey) {
       this.issueKey = issueKey;
   }


   public String doResolve() {
       if (StringUtils.isNotBlank(issueKey)) {
           taskResolver.resolve(issueKey);
       }
       return getRedirect("/secure/com.rozdoum.resolver.action.TaskResolverAction.jspa");
   }
}

Note that @Inject is not needed for in action constructor because an action itself is not a Component. It is created and managed by Webwork instead of Spring. All the constructor arguments are resolved as components, though, just like in regular JIRA component. JIRA does all the DI magic and injects requested components as constructor arguments. Also, there is no need to use @ComponentImport annotation for TaskResolver since it is not an external OSGi service, it’s a bean from the same bundle.

Creation of the JiraWebActionSupport in the classpath is not enough to make the new action working. It also needs to be defined in atlassian-plugin.xml — XML descriptor of the JIRA plugin. It is located in the src/main/resources directory. Add following Webwork configuration to the root XML element of the atlassian-plugin.xml.

<atlassian-plugin>
...
   <webwork1 key="task-resolver-action" class="java.lang.Object">
       <actions>
           <action name="com.rozdoum.resolver.action.TaskResolverAction" alias="com.rozdoum.resolver.action.TaskResolverAction">
               <view name="success">/templates/task-resolver.vm</view>
           </action>
       </actions>
   </webwork1>
...  
</atlassian-plugin>

Here com.rozdoum.resolver.action.TaskResolverAction class is defined as Webwork action mapped to com.rozdoum.resolver.action.TaskResolverAction alias URL path that is handled by this action. Action has success view references templates/task-resolver.vm — Velocity template which will be rendered in response to a request. Here is the template itself. Put it to src/main/resources/templates directory

<html>
   <head>
       <title>$action.getText('task-resolver.action.label')</title>
       <meta name="decorator" content="admin"/>
       <meta name="admin.active.section" content="system.admin/system">
       <meta name="admin.active.tab" content="task-resolver-web-item-link">
   </head>
   <body>
       <form action="com.rozdoum.resolver.action.TaskResolverAction!resolve.jspa" method="post">
           <input type="text" name="issueKey"/>
           <input type="submit" value="Resolve"/>
       </form>
   </body>
</html>

We also should provide a user a way to find TaskResolver among the giant number of standard JIRA actions and actions provided by other plugins. The best way to do that — to add a link to the action to system side bar. JIRA allows adding custom items to almost any bar or menu. Web-items are responsible for that. Here is the code to be added to the root element of the atlassian-plugin.xml.

<atlassian-plugin key="${atlassian.plugin.key}" name="${project.name}" plugins-version="2">
...
   <web-item key="task-resolver-web-item" name="Task Resolver Web Item" section="system.admin/system" weight="200">
       <label key="task-resolver.action.label"/>
       <link linkId="task-resolver-web-item-link">/secure/com.rozdoum.resolver.action.TaskResolverAction.jspa</link>
   </web-item>
...
</atlassian-plugin>

That’s it! The plugin is done. It’s time to launch JIRA and check if it works. Run the following command in the terminal.

atlas-run

It starts JIRA on http://localhost:2990/jira address. Open it in a browser, log into JIRA using preset credentials admin/admin. Prepare the environment: create a project and an issue. Then go to “Administration” -> ”Add-ons” -> “Task Resolver” (in the left sidebar). It should bring you to the following screen:

add-ons task resolder

It’s the result of the rendering of the /templates/task-resolver.vm template. Enter issue key into the text field and press “Resolve” button. Now check the status of the issue. Has it changed? Cheers!

Plugin works, and we’ve confirmed it, but it took around 5 minutes of our precious time to start JIRA instance, create a project, etc. It is not acceptable loss nowadays. Next step — to automate the process of testing that code works as expected. JIRA provides a special mechanism for testing of the plugins on a running system — wired tests. It’s regular integration tests that are launched after the JIRA is started. However, there is one significant difference from traditional integration tests: wired tests are running inside JIRA process whereas traditional integration tests run as a separate process. This allows interacting with native JIRA Java API right in the wired tests. Let’s create one. Put the following code to it.com.rozdoum.resolver package in src/test/java directory.

package it.com.rozdoum.resolver;


import com.atlassian.jira.bc.issue.IssueService;
import com.atlassian.jira.bc.issue.IssueService.CreateValidationResult;
import com.atlassian.jira.bc.issue.IssueService.IssueResult;
import com.atlassian.jira.bc.project.ProjectCreationData;
import com.atlassian.jira.bc.project.ProjectService;
import com.atlassian.jira.bc.project.ProjectService.CreateProjectValidationResult;
import com.atlassian.jira.config.IssueTypeManager;
import com.atlassian.jira.issue.Issue;
import com.atlassian.jira.issue.IssueInputParameters;
import com.atlassian.jira.issue.issuetype.IssueType;
import com.atlassian.jira.issue.status.Status;
import com.atlassian.jira.project.Project;
import com.atlassian.jira.security.JiraAuthenticationContext;
import com.atlassian.jira.user.ApplicationUser;
import com.atlassian.jira.user.UserUtils;
import com.atlassian.plugins.osgi.test.AtlassianPluginsTestRunner;
import com.rozdoum.resolver.api.TaskResolver;
import com.rozdoum.resolver.impl.TaskResolverImpl;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.runner.RunWith;


import java.util.Collection;


import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;


@RunWith(AtlassianPluginsTestRunner.class)
public class TaskResolverWiredTest {


   public static final String ADMIN_USERNAME = "admin";
   public static final String PROJECT_KEY = "TEST";
   public static final String ISSUE_TYPE_NAME = "Test issue type";


   private final TaskResolver taskResolver;
   private final JiraAuthenticationContext jiraAuthenticationContext;
   private final ProjectService projectService;
   private final IssueService issueService;
   private final IssueTypeManager issueTypeManager;


   private ApplicationUser admin;
   private Project project;
   private Issue issue;


   public TaskResolverWiredTest(TaskResolver taskResolver,
                                JiraAuthenticationContext jiraAuthenticationContext,
                                ProjectService projectService,
                                IssueService issueService,
                                IssueTypeManager issueTypeManager) {
       this.taskResolver = taskResolver;
       this.jiraAuthenticationContext = jiraAuthenticationContext;
       this.projectService = projectService;
       this.issueService = issueService;
       this.issueTypeManager = issueTypeManager;
   }


   @BeforeClass
   public void beforeClass() {
       admin = getAdmin();
       logIn(admin);


       project = projectService.getProjectByKey(PROJECT_KEY).getProject();
       if (project == null) {
           ProjectCreationData projectCreationData = new ProjectCreationData.Builder()
                   .withKey(PROJECT_KEY)
                   .withName("Test")
                   .withType("business")
                   .withLead(admin)
                   .build();
           CreateProjectValidationResult createProjectValidationResult = projectService.validateCreateProject(admin,
                   projectCreationData);
           project = projectService.createProject(createProjectValidationResult);
       }
   }


   @Before
   public void before() {
       IssueInputParameters issueInputParameters = issueService.newIssueInputParameters()
               .setProjectId(project.getId())
               .setIssueTypeId(getIssueTypeId())
               .setReporterId(admin.getName())
               .setSummary("some summary");
       CreateValidationResult createIssueValidationResult = issueService.validateCreate(admin, issueInputParameters);
       IssueResult issueResult = issueService.create(admin, createIssueValidationResult);
       issue = issueResult.getIssue();
   }


   @Test
   public void testTaskResolverIsInjected() throws Exception {
       assertNotNull(taskResolver);
   }


   @Test
   public void testIssueResolvedOnDemand() throws Exception {
       String issueKey = issue.getKey();
       taskResolver.resolve(issueKey);


       Issue issue = issueService.getIssue(admin, issueKey).getIssue();
       Status status = issue.getStatus();
       String actualStatusName = status.getName();
       String expectedStatusName = TaskResolverImpl.RESOLVED_STATUS_NAME;
       assertEquals("issue should have been resolved", expectedStatusName, actualStatusName);
   }


   private ApplicationUser getAdmin() {
       return UserUtils.getUser(ADMIN_USERNAME);
   }


   private void logIn(ApplicationUser user) {
       jiraAuthenticationContext.setLoggedInUser(user);
   }


   private String getIssueTypeId() {
       IssueType issueType = findIssueTypeByName(ISSUE_TYPE_NAME);
       if (issueType == null) {
           issueType = issueTypeManager.createIssueType(ISSUE_TYPE_NAME, null, 0L);
       }


       String issueTypeId = issueType.getId();
       return issueTypeId;
   }


   private IssueType findIssueTypeByName(String issueTypeName) {
       IssueType foundIssueType = null;
       Collection issueTypes = issueTypeManager.getIssueTypes();
       for (IssueType issueType : issueTypes) {
           if (issueType.getName().equals(issueTypeName)) {
               foundIssueType = issueType;
               break;
           }
       }


       return foundIssueType;
   }
}

Here are important points about the test:

  • It looks like a regular JUnit test, but it is not :)

  • @RunWith (AtlassianPluginsTestRunner.class) annotation says JUnit to run the test with a custom Atlassian runner that performs all the magic. All the wired tests should be marked with this annotation. Test without this annotation is considered as regular JUnit test, and it is executed outside of JIRA with no access to its APIs.

  • The constructor may get arguments. They are considered as components that are injected into the test during the instantiation. Regular JUnit tests require constructor with no arguments.

  • Setup method marked with @BeforeClass should not be static.

Since the wired test is a regular integration test, it requires environment setup. That is why it contains quite much code for a project, issue type, and issue creation. After setup is done, it’s time to test TaskResolver. It is injected along with other components. However in difference to other components TaskResolver is provided by another OSGi bundle the bundle of the main plugin so it has to be imported explicitly to a test plugin bundle. To do it add package import statements to the <plugin-info> element of the atlassian-plugin.xml in src/test/resources directory.

<atlassian-plugin>
   <plugin-info>
...
       <bundle-instructions>
           <Import-Package>
               com.rozdoum.resolver.api,
           </Import-Package>
       </bundle-instructions>
   </plugin-info>
...
</atlassian-plugin>

OSGi service needs to be included individually. Add the following to the root element of the src/test/resources/atlassian-plugin.xml

<atlassian-plugin>
...
   <component-import key="taskResolver" interface="com.rozdoum.resolver.api.TaskResolver"/>
...
</atlassian-plugin>

That’s it. Stop the JIRA if it’s running and execute the following command. It will start JIRA, run all the wired tests and stop JIRA. Test results will be shown in the log.

atlas-integration-test

Thanks for your patience. Qualitative requires perseverance :) Sources of this sample can be found here: https://git.rozdoum.com/users/michaelv/repos/task-resolver


 

Atlassian Team
 Author: Michael Vlasov
 Atlassian Developer at Rozdoum

 


Posted 2016-11-09 12:24 in Atlassian news Software Tools Trends

Go back