New Technologies

How to Develop a Plugin for Atlassian Jira

This is where the Jira plug-in mechanism comes in handy as it enables you to crunch through Jira’s repository for your own purposes.

24 minutes to read

  • Read more about how to gain specific insights about your projects that Atlassian developers did not foresee.

  • Learn about obstacles overcome while developing a plugin to extract custom project metrics from Atlassian Jira 7.12.3.

Atlassian’s Jira is a very popular tool for planning and tracking projects. The data that Jira accumulates is a gold-mine for obtaining insights about productivity improvements. While some of this data is readily accessible for analysis with out-of-the box Jira features, you are likely to want to gain specific insights about your projects that Atlassian developers did not foresee.

This is where the Jira plug-in mechanism comes in handy as it enables you to crunch through Jira’s repository for your own purposes.

Atlassian’s Jira is a very popular tool for planning and tracking projects. The data that Jira accumulates is a gold-mine for obtaining insights about productivity improvements. While some of this data is readily accessible for analysis with out-of-the box Jira features, you are likely to want to gain specific insights about your projects that Atlassian developers did not foresee.

This is where the Jira plug-in mechanism comes in handy as it enables you to crunch through Jira’s repository for your own purposes.

The following is a lengthy and quite technical blog post about obstacles overcome while developing a plugin to extract custom project metrics from Atlassian Jira 7.12.3.

You may have come to this page seeking a solution to one of the following error messages:

  • Unexpected exception parsing XML document from URL [bundle://....0:0/META-INF/spring/atlassian-plugins-component-imports.xml]; nested exception is org.springframework.beans.FatalBeanException: Class [org.eclipse.gemini.blueprint.config.OsgiNamespaceHandler] for namespace [http://www.eclipse.org/gemini/blueprint/schema/blueprint] does not implement the [org.springframework.beans.factory.xml.NamespaceHandler] interface
  • Configuration problem: Unable to locate Spring NamespaceHandler for XML schema namespace [http://www.eclipse.org/gemini/blueprint/schema/blueprint]
    Offending resource: URL [bundle://....0:0/META-INF/spring/atlassian-plugins-component-imports.xml]
  • Cannot start plugin: Unresolved constraint in bundle ... [...]: Unable to resolve ....0: missing requirement [....0] osgi.wiring.package; (osgi.wiring.package=COM.jrockit.reflect)
  • Cannot start plugin: Unresolved constraint in bundle ... [...]: Unable to resolve ....0: missing requirement [....0] osgi.wiring.package; (osgi.wiring.package=com.atlassian.tutorial.myPlugin.api) [caused by: Unable to resolve ....0: missing requirement [....0] osgi.wiring.package; (osgi.wiring.package=org.springframework.osgi.service.exporter.support)]
  • '...-tests' - '...'  failed to load. The plugin has been disabled.
    A likely cause is that it timed out during initialisation
    It has the following missing service dependencies : ...
  • lang.Exception: No tests found in class [it....]
  • No tests found
  • lang.NoClassDefFoundError: com/atlassian/jira/util/collect/MapBuilder

The article below records the steps I took to overcome the above issues. I don’t claim that it is the only way to solve them, just that it worked for me. Unfortunately, if you are new to Atlassian plugin development the learning curve can be very steep and the documentation can be hard to find. I hope I can save you a few hours or days of searching for the correct information. References to some relevant Atlassian documentation can be found at the end.

My sincere thanks to several Zuhlke colleagues who worked on the solution with me: Mirko Sciachero, Giovanni Asproni, Michael Dickens and Michael Scott.

A simple starter-for-ten Jira plugin.

agile metrics

A little architectural background

When I started this work, I knew nothing about the way the Jira server is built, but I soon discovered that I needed to be better informed if I was to have any chance of working out what was going wrong. The following diagram is a very simple-minded idea of the way in which dependencies are fulfilled within the web application.

The plugin we wish to develop runs within the context of a multi-threaded web app (Jira).

Java VM

Integration/wired tests are run from a complementary plugin installed within the same environment.

Both plugins have dependencies on multiple Java libraries. A Java application satisfies its dependencies at runtime by calling its class loader to obtain them. Standard class loaders delegate to their parents first, and only start searching the locations on their class path if the parent is unable to load the required resource or class.

However, in the case of Jira it’s different. Jira wants to avoid every plugin loading copies of the same class, as this could easily increase the amount of memory used for class storage by a few orders of magnitude. It uses the OSGi framework, which comes with Spring, to provide a cache of already-loaded classes (called Felix) and to satisfy all dependencies from this cache.

This has important implications for the plugin configuration. Felix only loads classes permitted by the plugin configuration. This configuration is composed of instructions in the atlassian-plugin.xml file (a resource), the project POM, and some Atlassian defaults computed at build time.

The result is that it is very easy to encounter NoClassDefFound exceptions even though the required class is on the class path. Whenever this happens, it is worth checking the plugin configuration for missing package-import statements.

Getting started

Installing the tools

I am working on a MacBook Pro, so I have installed:

  • Java 8 JDK from Oracle
  • XCode from the App Store (this provides, inter alia, a command-line git client, which you will need – but there are other ways to obtain one)
  • IntelliJ IDEA direct from the Jetbrains site (I prefer a paid-for license, but the free community edition would do)
  • The Atlassian SDK – it turns out that Homebrew is the preferred way to install this, as the pkg installer makes unwarranted assumptions about your version of Java, Python and more

Start your project

The first thing to do is to decide how you’re going to version-control your work. I am using git with an in-house Bitbucket service, so I created a repository via the Web UI on bitbucket and cloned it to my workspace.

Use the sequence of instructions in the Hello World tutorial to create a plugin skeleton – but select your values for Maven group ID, Maven artifact ID, Maven version and Java package according to your real needs. For example (your inputs in bold):

$ git clone .../myPlugin.git
$ atlas-create-jira-plugin
Executing: /Applications/Atlassian/atlassian-plugin-sdk-6.3.12/apache-maven-3.2.1/bin/mvn com.atlassian.maven.plugins:maven-jira-plugin:6.3.21:create -gs /Applications/Atlassian/atlassian-plugin-sdk-6.3.12/apache-maven-3.2.1/conf/settings.xml
readlink: illegal option -- f
usage: readlink [-n] [file ...]
Warning: JAVA_HOME environment variable is not set.
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=256M; support was removed in 8.0
[INFO] Scanning for projects...
[INFO] 
[INFO] Using the builder org.apache.maven.lifecycle.internal.builder.singlethreaded.SingleThreadedBuilder with a thread count of 1
[INFO]                                                                         
[INFO] ------------------------------------------------------------------------
[INFO] Building Maven Stub Project (No POM) 1
[INFO] ------------------------------------------------------------------------
[INFO] 
[INFO] --- maven-jira-plugin:6.3.21:create (default-cli) @ standalone-pom ---
[INFO] Google Analytics Tracking is enabled to collect AMPS usage statistics.
[INFO] Although no personal information is sent, you may disable tracking by adding <allowGoogleTracking>false</allowGoogleTracking> to the amps plugin configuration in your pom.xml
[INFO] Sending event to Google Analytics: AMPS:jira - Create Plugin
[INFO] using stable product version: 7.12.0
[INFO] using stable data version: 7.12.0
Define value for groupId: : com.zuhlke.training.jira
Define value for artifactId: : myPlugin
Define value for version:  1.0.0-SNAPSHOT: : 
Define value for package:  com.zuhlke.training.jira: : 
Confirm properties configuration:
groupId: com.zuhlke.training.jira
artifactId: myPlugin
version: 1.0.0-SNAPSHOT
package: com.zuhlke.training.jira
 Y: : 
[INFO] Setting property: classpath.resource.loader.class => 'org.codehaus.plexus.velocity.ContextClassLoaderResourceLoader'.
[INFO] Setting property: velocimacro.messages.on => 'false'.
[INFO] Setting property: resource.loader => 'classpath'.
[INFO] Setting property: resource.manager.logwhenfound => 'false'.
[INFO] Generating project in Batch mode
[INFO] Archetype repository missing. Using the one from [com.atlassian.maven.archetypes:jira-plugin-archetype:5 -> https://maven.atlassian.com/public] found in catalog internal
[WARNING] The directory /Users/ihu/IdeaProjects/myPlugin already exists.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 41.487 s
[INFO] Finished at: 2018-11-22T11:17:01+00:00
[INFO] Final Memory: 16M/223M
[INFO] ------------------------------------------------------------------------
$ ls -al myPlugin
total 40
drwxr-xr-x   8 ihu  staff   256 22 Nov 11:17 .
drwxr-xr-x   9 ihu  staff   288 22 Nov 11:15 ..
drwxr-xr-x  10 ihu  staff   320 22 Nov 11:15 .git
-rw-r--r--   1 ihu  staff  3659 22 Nov 11:15 .gitignore
-rw-r--r--   1 ihu  staff   310 22 Nov 11:17 LICENSE
-rw-r--r--   1 ihu  staff   625 22 Nov 11:17 README
-rw-r--r--   1 ihu  staff  1325 22 Nov 11:15 README.md
-rw-r--r--   1 ihu  staff  7658 22 Nov 11:17 pom.xml
drwxr-xr-x   4 ihu  staff   128 22 Nov 11:17 src
$ cd myPlugin
$ atlas-integration-test

You can now open (no need to import) the workspace in IDEA in order to configure README.md and .gitignore files appropriately. NB the Atlassian tool atlas-create-jira-plugin will create a README file (without an extension) – not to be confused with the markdown file README.md, which github or bitbucket servers will display on the project’s home page.

Before starting to modify anything, commit your initial checkpoint to version control. The easiest way is to open a terminal window in IDEA and enter the following two commands. Subsequent commits are more easily performed within the IDE, using CMD-K.

$ git add .
$ git commit -m 'Initial version'

Things to look out for

  • Make sure that the correct version of Java is used both to run your IDE and to run Jira (see above)
    • If you need multiple versions of Java JDK on your machine, consider the use of jEnv to manage the version in use (sorry Windows users – not available for you)
       
  • Configure IDEA via its preferences dialog to use atlas-mvn instead of the default bundled mvn executable
    • Maven home directory: /Applications/Atlassian/atlassian-plugin-sdk-6.3.12/apache-maven-3.2.1 (or similar)
       
  • If not automatically set, configure the IDEA project SDK to match your version of Java
    • Right-click the top level of the hierarchy under the Project Browser and select “open module settings”
    • Alternatively, click the prompt at the top of any Java source window if it warns you that the project SDK has not been set
       
  • Use the latest version of Jira both for the testkit and for Jira itself by adjusting the properties values at the bottom of the pom.xml file (I used 7.12.3 for both)
     
  • Add JVM arguments to the maven-jira-plugin in the POM to increase the amount of heap
    • See complete plugin configuration under “Final configuration” below
       
  • Look out for the scope of dependencies in the Maven POM – most artifacts should be “provided” or possibly “test” (e.g. com.google.code.gson:gson should be “provided”)
    • Search the target folder after executing atlas-run, atlas-package or atlas-integration-test to see which versions of libraries have been provided
    • Only specify “compile” scope for packages that Jira does not provide, but that your main plugin depends on (atlassian-spring-scanner-annotations appears to be an exception to this rule)
    • Give “runtime” scope to the dependency on atlassian-spring-scanner-runtime
       
  • Add the following property values to the POM:
        <junit.version>4.11</junit.version>
        <felix.framework.version>4.2.1</felix.framework.version>
        <gemini.blueprint.version>2.0.5.BUILD-atlassian-m002</gemini.blueprint.version>
  • Set the version of the junit dependency to ${junit.version}
  • Add the following dependencies to support integration testing:
    • com.atlassian.plugins.rest:atlassian-rest-module:3.2.18:provided
    • com.atlassian.sal:sal-api:3.1.0:provided
    • org.eclipse.gemini.blueprint:gemini-blueprint-core:${gemini.blueprint.version}:provided
    • com.atlassian.jira.tests:jira-testkit-client (just uncomment)
    • com.atlassian.jira:jira-tests:${jira-version}:test
    • org.slf4j:slf4j-log4j12:1.7.9:test
    • junit:junit-dep:${junit-version}:test
       
  • Do not let out-of-date tutorial documentation mislead you into adding a dependency on jira-func-test – this seems to be incompatible with Jira 7
  • In IDEA, if the source folders are not identified automatically, you may have to right-click the POM file and select “Maven -> import as Maven project” or “Maven -> reimport”
     
  • No Import-Package section should be needed in the main plugin’s atlassian-plugin.xml, because the imports are specified in the maven-jira-plugin configuration. However, you will need it in the test plugin (see below)
     
  • The fastdev console (see illustration below) is deprecated – if you enable it in the POM, you get lots of verbose warnings.
    • FastDev Console (FDC) would clash with QuickReload (qr) so you can only enable one or the other
    • Only if you have enabled FDC will you be able to initiate integration tests from the Web UI – this is useful if you wish to remote-debug the plugin code on the server. This is the only circumstance under which I found it useful to enable FDC
    • Whether you enable qr or FDC, running the atlas-package command will reload both the custom plugin and its plugin-tests plugin. However, in the FDC case, attempting to run the integration tests from the Web UI after a reload causes a server crash, while in the qr case it does nothing at all (the client receives a 404 error from the server because it tries to invoke a FastDev API)
    • However, there is a workaround – see Speeding up Plugin Testing at the end of this article.
    • Import-Package may specify ‘;version=”0″‘ if you don’t care which version of the dependency is required – normally you would not specify a version
       
  • Import-Package should usually specify ‘;resolution:=”optional”‘ – this ensures that the plugin will not fail to load just because some dependency is not available at the time

  • Before you can use the IDE effectively, it’s necessary to execute atlas-runatlas-package or atlas-integration-test at least once. This downloads the required dependencies using the Maven dependency resolution mechanism.
Atlassian Plugin Test Console

User Experience

Customise the plugin

However, you can skip the creation of the “mySection” and “myItem” plugin modules.

If you wish to include a Web UI in your plugin, which is the usual situation, the recommended way to create an initial landing page is to use the command atlas-create-jira-plugin-module to add plugin modules of type Web Item (25) and Web Panel (26). The web item comes first, even though it has to reference the web panel, which comes second.

New menu entry with custom icon added to Atlassian Jira

Below, I specify jira.project.sidebar.plugins.navigation as the location where the new menu item should appear. This corresponds to the left-hand menu displayed when the user has navigated to a specific project. I have been unable to find a consolidated list of Jira user interface locations (called sections) where web items, pages, panels and so on can be inserted. Here are some you may find useful:

Atlassian also recommends a third-party utility called Web fragments to find sections/locations.

Assuming you wish to use an icon that resembles those of the existing menu items, create this as e.g. arrows.png and store it in the folder src/main/resources/images before you begin. Note that it is extremely difficult to discover in Atlassian’s published documentation how to access an image you have added to the above folder. The correct URL for a Jira web resource is shown below.

Alternatively, define the icon as a SVG drawing embedded in a stylesheet, as the system icons are. Such icons are referenced from within atlassian-plugin.xml using a style reference, so you would omit the advanced setup from the web item in the dialog below, and instead edit the file directly afterwards. See below.

$ atlas-create-jira-plugin-module 
Executing: /Applications/Atlassian/atlassian-plugin-sdk-6.3.12/apache-maven-3.2.1/bin/mvn com.atlassian.maven.plugins:maven-jira-plugin:6.3.21:create-plugin-module -gs /Applications/Atlassian/atlassian-plugin-sdk-6.3.12/apache-maven-3.2.1/conf/settings.xml
readlink: illegal option -- f
usage: readlink [-n] [file ...]
Warning: JAVA_HOME environment variable is not set.
Java HotSpot(TM) 64-Bit Server VM warning: ignoring option MaxPermSize=256M; support was removed in 8.0
[INFO] Scanning for projects...
[INFO] 
[INFO] Using the builder org.apache.maven.lifecycle.internal.builder.singlethreaded.SingleThreadedBuilder with a thread count of 1
[INFO]                                                                         
[INFO] ------------------------------------------------------------------------
[INFO] Building myPlugin 1.0.0-SNAPSHOT
[INFO] ------------------------------------------------------------------------
[INFO] 
[INFO] --- maven-jira-plugin:6.3.21:create-plugin-module (default-cli) @ myPlugin ---
Choose Plugin Module:
...
25: Web Item
26: Web Panel
...
Choose a number (1/2/3/4/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19/20/21/22/23/24/25/26/27/28/29/30/31/32/33/34): 25
[INFO] Google Analytics Tracking is enabled to collect AMPS usage statistics.
[INFO] Although no personal information is sent, you may disable tracking by adding <allowGoogleTracking>false</allowGoogleTracking> to the amps plugin configuration in your pom.xml
[INFO] Sending event to Google Analytics: AMPS:jira - Create Plugin Module - jira:Web Item
Enter Plugin Module Name My Web Item: : metrics-page-item
Enter Section (e.g. system.admin/globalsettings): jira.project.sidebar.plugins.navigation
Enter Link URL (e.g. /secure/CreateIssue!default.jspa): /projects/${pathEncodedProjectKey}?selectedItem=com.atlassian.jira.jira-projects-plugin:metrics-page
Show Advanced Setup? (Y/y/N/n) N: : y
Module Key metrics--page--item: : 
Module Description The metrics-page-item Plugin: : Menu item for Metrics Page
i18n Name Key metrics--page--item.name: : 
i18n Description Key metrics--page--item.description: : 
Weight 1000: : 
Link Id metrics--page--item-link: : 
Enter Label Key metrics--page--item.label: : 
Enter Label Value metrics-page-item: : Agile Metrics
Add Label Param? (Y/y/N/n) N: : 
Add Icon? (Y/y/N/n) N: : y
Icon Location (e.g. /images/icons/print.gif): ${baseurl}/download/resources/${atlassian.plugin.key}:myPlugin-resources/images/arrows.png
Icon Width 16: : 32
Icon Height 16: : 32
Add Tooltip? (Y/y/N/n) N: : 
Add Resource (Y/y/N/n) N: : 
Add Velocity Context Provider (Y/y/N/n) N: : 
Add Plugin Module Param? (Y/y/N/n) N: : 
Add Conditions? (Y/y/N/n) N: : 
[INFO] Adding the following items to the project:
[INFO]   [dependency: org.mockito:mockito-all]
[INFO]   [module: web-item]
[INFO]   i18n strings: 3
Add Another Plugin Module? (Y/y/N/n) N: : y
Choose Plugin Module:
...
25: Web Item
26: Web Panel
...
Choose a number (1/2/3/4/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19/20/21/22/23/24/25/26/27/28/29/30/31/32/33/34): 26
Enter Plugin Module Name My Web Panel: : metrics-page
Enter Location (e.g. system.admin/globalsettings): com.atlassian.jira.jira-projects-plugin:metrics-page
Show Advanced Setup? (Y/y/N/n) N: : y
Module Key metrics--page: : 
Module Description The metrics-page Plugin: : Agile Metrics page
i18n Name Key metrics--page.name: : 
i18n Description Key metrics--page.description: : 
Weight 1000: : 
Add Resource (Y/y/N/n) N: : y
Enter Resource Name (leave blank to use namePattern): view
Enter Resource Type download: : velocity
Enter Location (path to resource file): templates/tabpanels/metrics-tab-panel.vm
Add Resource Parameter? (Y/y/N/n) N: :
Add Resource (Y/y/N/n) N: : 
Add Velocity Context Provider (Y/y/N/n) N: : y
Enter Fully Qualified Context Provider Class com.zuhlke.training.jira.web.contextproviders.MyContextProvider: : com.zuhlke.training.jira.impl.MetricsInfoImpl
Add Conditions? (Y/y/N/n) N: : 
[INFO] Adding the following items to the project:
[INFO]   [dependency: org.mockito:mockito-all]
[INFO]   [module: web-panel]
[INFO]   i18n strings: 2
Add Another Plugin Module? (Y/y/N/n) N: : 
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 03:38 min
[INFO] Finished at: 2018-11-22T14:14:13+00:00
[INFO] Final Memory: 26M/378M
[INFO] ------------------------------------------------------------------------

As an alternative to an icon element containing a link to a web resource image file, stylesheet icons are referenced like this (e.g. the “reports” icon):

<param name="iconClass" value="aui-icon-large agile-icon-report aui-iconfont-graph-line"/>

Connect the UI to Jira Data

Access to Jira Data

At this point we have to provide additional source files: the Velocity template and the context provider class mentioned in the module configuration above. Suggested initial, extremely simple implementations are shown below.

The Velocity template is a recipe for generating a chunk of HTML that the web application server inserts in a web page. As you can see in the example below, it obtains variable text from context variables. In this case, aside from the built-in variables such as $i18n, which is an object capable of returning localised text for any given key (and which should of course be used for all the field labels instead of hard-coding them), we need a variable named $project. The context provider class (in this example, MetricsInfoImpl) is named in the web-panel module defined earlier. It has to provide a public method getContextMap, which returns the context information in the form of key-value pairs. Values can be complex objects – Velocity is clever enough to dereference the required attribute or property by repeatedly calling get… until it has descended to the desired level (e.g. $project.lead.name).

metrics-tab-panel.vm (in src/main/resources/templates/tabpanels)

<div class="mod-header">
    <h3>$i18n.getText('metrics--page--item.label')</h3>
    <table>
        <tr>
            <td>Name</td><td>$project.name</td>
        </tr>
        <tr>
            <td>Key</td><td>$project.key</td>
        </tr>
        <tr>
            <td>Description</td><td>$project.description</td>
        </tr>
        <tr>
            <td>Project Lead Key</td><td>$project.lead.key</td>
        </tr>
        <tr>
            <td>Project Lead Name</td><td>$project.lead.name</td>
        </tr>
        <tr>
            <td>Project Lead Display Name</td><td>$project.lead.displayName</td>
        </tr>
        <tr>
            <td>Project Lead Email Address</td><td>$project.lead.emailAddress</td>
        </tr>
    </table>
</div>

MetricsInfo.java

 

package com.zuhlke.training.jira.api;

import com.atlassian.jira.plugin.webfragment.model.JiraHelper;
import com.atlassian.jira.user.ApplicationUser;

import java.util.Map;

public interface MetricsInfo {
    String PROJECT = "project";

    Map getContextMap(ApplicationUser applicationUser, JiraHelper jiraHelper);
}

MetricsInfoImpl.java

There are numerous ways to get at information in Jira. Where the desired information is related to a project, the UserProjectHistoryManager turns out to be the most straightforward. There is another class named ProjectManager, which is easier to use in some circumstances – particularly where you already know the project key or you want a list of all projects in the system. However, in this case, we just want the currently selected project.

package com.zuhlke.training.jira.impl;

import com.atlassian.jira.plugin.webfragment.contextproviders.AbstractJiraContextProvider;
import com.atlassian.jira.plugin.webfragment.model.JiraHelper;
import com.atlassian.jira.project.Project;
import com.atlassian.jira.user.ApplicationUser;
import com.atlassian.jira.user.UserProjectHistoryManager;
import com.atlassian.plugin.spring.scanner.annotation.component.Scanned;
import com.atlassian.plugin.spring.scanner.annotation.imports.ComponentImport;

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

import static com.atlassian.jira.security.Permissions.PROJECT_ADMIN;

@Scanned
public class MetricsInfoImpl extends AbstractJiraContextProvider implements com.zuhlke.training.jira.api.MetricsInfo {

    @ComponentImport
    private final UserProjectHistoryManager userProjectHistoryManager;

    public MetricsInfoImpl(UserProjectHistoryManager userProjectHistoryManager){
        this.userProjectHistoryManager = userProjectHistoryManager;
    }

    @Override
    public Map getContextMap(ApplicationUser applicationUser, JiraHelper jiraHelper) {
        Map<String, Object> contextMap = new HashMap<>();

        Project currentProject = userProjectHistoryManager.getCurrentProject(PROJECT_ADMIN, applicationUser);
        if(null != currentProject) {
            contextMap.put(PROJECT, currentProject);
        }

        return contextMap;
    }
}

The annotation @Scanned tells the Atlassian Spring scanner to scan the class at build time. From https://www.j-tricks.com/tutorials/atlassian-spring-scanner-and-nosuchbeandefinitionexception:

  • Anything in the constructor needs the @ComponentImport annotation and the class needs a @Scanned annotation
  • You can use the components using ComponentAccessor, without the annotations:
    YourComponent yourComponent = ComponentAccessor.getComponent(YourComponent.class);
  • But make sure that your component class itself has the required annotations to publish the class as a component: @Component and @ExportAsService, as required.

It is therefore probably a good idea to add the @Scanned annotation to the MyPluginComponentImpl class too, for the sake of consistency.

Final configuration

Eventual content of atlassian-plugin.xml:

<?xml version="1.0" encoding="UTF-8"?>

<atlassian-plugin key="${atlassian.plugin.key}" name="${project.name}" plugins-version="2">
  <plugin-info>
    <description>${project.description}</description>
    <version>${project.version}</version>
    <vendor name="${project.organization.name}" url="${project.organization.url}"/>
    <param name="plugin-icon">images/pluginIcon.png</param>
    <param name="plugin-logo">images/pluginLogo.png</param>
  </plugin-info>
  <!-- add our i18n resource -->
  <resource type="i18n" name="i18n" location="myPlugin"/>
  <!-- add our web resources -->
  <web-resource key="myPlugin-resources" name="myPlugin Web Resources">
    <dependency>com.atlassian.auiplugin:ajs</dependency>
    <resource type="download" name="myPlugin.css" location="/css/myPlugin.css"/>
    <resource type="download" name="myPlugin.js" location="/js/myPlugin.js"/>
    <resource type="download" name="images/" location="/images"/>
    <context>myPlugin</context>
  </web-resource>
  <web-item name="metrics-page-item" i18n-name-key="metrics--page--item.name" key="metrics--page--item" section="jira.project.sidebar.plugins.navigation" weight="1000">
    <description key="metrics--page--item.description">Menu item for Metrics Page</description>
    <icon width="32" height="32">
      <link linkId="metrics--page--item-icon">${baseurl}/download/resources/${atlassian.plugin.key}:myPlugin-resources/images/arrows.png</link>
    </icon>
    <label key="metrics--page--item.label"/>
    <link linkId="metrics--page--item-link">/projects/${pathEncodedProjectKey}?selectedItem=com.atlassian.jira.jira-projects-plugin:metrics-page</link>
  </web-item>

  <web-panel name="metrics-page" i18n-name-key="metrics--page.name" key="metrics--page" location="com.atlassian.jira.jira-projects-plugin:metrics-page" weight="1000">
    <description key="metrics--page.description">Agile Metrics page</description>
    <context-provider class="com.zuhlke.training.jira.impl.MetricsInfoImpl"/>
    <resource name="view" type="velocity" location="templates/tabpanels/metrics-tab-panel.vm"/>
  </web-panel>
</atlassian-plugin>

The full specification of the maven-jira-plugin in pom.xml:

<plugin>
    <groupId>com.atlassian.maven.plugins</groupId>
    <artifactId>maven-jira-plugin</artifactId>
    <version>${amps.version}</version>
    <extensions>true</extensions>
    <configuration>
        <productVersion>${jira.version}</productVersion>
        <productDataVersion>${jira.version}</productDataVersion>
        <jvmArgs>-Xms1g -Xmx1g -XX:MaxPermSize=1g -XX:-UseGCOverheadLimit -server</jvmArgs>
        <!--Uncomment to install TestKit backdoor in JIRA.-->
        <pluginArtifacts>
            <pluginArtifact>
                <groupId>com.atlassian.jira.tests</groupId>
                <artifactId>jira-testkit-plugin</artifactId>
                <version>${testkit.version}</version>
            </pluginArtifact>
        </pluginArtifacts>
        <enableQuickReload>true</enableQuickReload>
        <enableFastdev>false</enableFastdev>
        <!-- See here for an explanation of default instructions: -->
        <!-- https://developer.atlassian.com/docs/advanced-topics/configuration-of-instructions-in-atlassian-plugins -->
        <instructions>
            <Atlassian-Plugin-Key>${atlassian.plugin.key}</Atlassian-Plugin-Key>
            <!-- Add package to export here -->
            <Export-Package>
                <!-- package name from your interface e.g. MyPluginComponent.java here -->
                path.to.api
            </Export-Package>
            <!-- Add package import here -->
            <Import-Package>
                org.springframework.osgi.*;resolution:="optional",
                org.eclipse.gemini.blueprint.*;resolution:="optional",
                *
            </Import-Package>
            <!-- Ensure plugin is spring powered -->
            <Spring-Context>*</Spring-Context>
        </instructions>
    </configuration>
</plugin>

Start the Server

At this point, you should be able to execute atlas-clean followed by atlas-run or atlas-debug. Note that starting the server takes a minute or two, and you’ll probably notice a series of stack traces complaining about missing jar files. Provided that you don’t find the string “failed to load” in the output, there is nothing to worry about. Point your browser at http://localhost:2990/jira and log in with admin/admin.

If things are going wrong, check the log files in these locations:

  • target/container/tomcat8x/cargo-jira-home/logs
  • target/jira/home/log

Don’t forget to commit your work!

Unit Tests

Automated Testing

So far, we have only used the very rudimentary unit and integration tests generated by the Atlassian tools. In order to perform proper test-driven development (TDD), these need to be extended. In general, there should be a thorough unit test of each custom Java module and a simpler integration test of the same, which tests the “happy day” scenarios as well as one or two of the principal failure modes.

Of course the MetricsInfoImpl class was developed using TDD, although I presented the finished code above as a fait accompli.

Unit Tests

All unit tests are located in the src/test/java source folder under the package ut.path.to.your.package. In this example, the package name is ut.com.zuhlke.training.jira (generated automatically by the initial atlas-create-jira-plugin command). These tests run both under the IDE and under Maven whenever the software is built. I will present one happy-day test case to show how the whole thing works.

The unit tests have to emulate the context in which the plugin will run. This makes the setup quite verbose. Jira uses the mockito framework to make it easy to mock the environment – you just need to know what parts of the environment to mock!

Start by creating the Java class MetricsInfoImplUnitTest and writing a failing test:

package ut.com.zuhlke.training.jira;

import com.zuhlke.training.jira.api.MetricsInfo;
import com.zuhlke.training.jira.impl.MetricsInfoImpl;
import org.junit.Test;

import java.util.Map;

import static org.junit.Assert.assertNotNull;

public class MetricsInfoImplUnitTest {

    @Test
    public void shouldRetrieveContextMapForProject() {

        final MetricsInfo metricsInfo = new MetricsInfoImpl(userProjectHistoryManager);
        final Map<String, Object> contextMap = metricsInfo.getContextMap(applicationUser, jiraHelper);

        assertNotNull("contextMap should not be null", contextMap);
    }
}

This fails to compile, because the objects userProjectHistoryManager, applicationUser and jiraHelper are undefined. Add some mocks to satisfy these requirements (above the first test case):

@Rule
public MockitoContainer mockitoContainer = MockitoMocksInContainer.rule(this);

@Mock
private UserProjectHistoryManager userProjectHistoryManager;

@Mock
private ApplicationUser applicationUser;

@Mock
private JiraHelper jiraHelper;

The IDE automatically adds the necessary imports:

import com.atlassian.jira.junit.rules.MockitoContainer;
import com.atlassian.jira.junit.rules.MockitoMocksInContainer;
import com.atlassian.jira.plugin.webfragment.model.JiraHelper;
import com.atlassian.jira.user.ApplicationUser;
import com.atlassian.jira.user.UserProjectHistoryManager;
import org.junit.Rule;
import org.mockito.Mock;

Run the test to prove that it fails. Surprisingly perhaps, it passes – because of the defensive code in the implementation class, which I inserted following the failure of this test. So let’s add another assertion:

assertEquals("Project", project, contextMap.get(PROJECT));

This adds another import and requires us to add another mock object. This one is provided in quite a different manner. Just above the test case, add the following:

final String projectKey            = "SAM";
final String projectName           = "SampleProject";
final MockProject project          = new MockProject(10001L, projectKey, projectName);

The following imports are added automatically (you may have to respond to the IDE’s prompt with ALT-ENTER):

import static com.zuhlke.training.jira.api.MetricsInfo.PROJECT;
import com.atlassian.jira.project.MockProject;
import static org.junit.Assert.assertEquals;

Rather satisfyingly, the test now runs and fails. The context map has returned null when the object with the key PROJECT was requested. So a little more initialisation is needed at the start of the test case:

Mockito.when(userProjectHistoryManager.getCurrentProject(PROJECT_ADMIN, applicationUser)).thenReturn(project);

This adds another import:

import static com.atlassian.jira.security.Permissions.PROJECT_ADMIN;

The test now passes. Just to be sure that we can access properties of the returned object, add the following to the end of the test case:

final Project contextProject = (Project) contextMap.get(PROJECT);
assertEquals("Project Name", project.getName(), contextProject.getName());

The IDE adds the import:

import com.atlassian.jira.project.Project;

Wired Tests 1

Integration Tests

All integration tests are located under the package it.path.to.your.implementation – in this case, it.com.zuhlke.training.jira. These tests can only be run using the command atlas-integration-test or using the deprecated FastDev Console (see above).

Configuration for wired tests

Before you can use the context provider class in a wired test, you will have to import it to the test plugin as a component. And before you can do that, you’ll have to add annotations to export it appropriately. Add the following annotations to the MetricsInfoImpl class:

@ExportAsService({MetricsInfo.class})
@Named("metricsInfo")

Add the following annotations to its constructor function:

@Inject
@Autowired

The IDE will insert the following imports:

import com.atlassian.plugin.spring.scanner.annotation.export.ExportAsService;
import com.zuhlke.training.jira.api.MetricsInfo;
import org.springframework.beans.factory.annotation.Autowired;
import javax.inject.Inject;
import javax.inject.Named;

Then add a component-import statement to the file src/test/resources/atlassian-plugin.xml:

<component-import key="metricsInfo" interface="com.zuhlke.training.jira.api.MetricsInfo"/>

Trivial Wired Test

We can now start using this class in wired tests. Start by creating the Java class MetricsInfoImplWiredTest. Annotate the class as follows:

@RunWith(AtlassianPluginsTestRunner.class)

This test runner requires a constructor with parameters corresponding to one or more of the component-imports quoted in the file src/test/resources/atlassian-plugin.xml. If you’re using an IDE, auto-generate the constructor and then change its signature to add the parameters, binding them to private final fields:

public MetricsInfoImplWiredTest(ApplicationProperties applicationProperties,
        MetricsInfo metricsInfo) {
    this.applicationProperties = applicationProperties;
    this.metricsInfo = metricsInfo;
}

You should now have these imports:

import com.atlassian.plugins.osgi.test.AtlassianPluginsTestRunner;
import com.atlassian.sal.api.ApplicationProperties;
import org.junit.runner.RunWith;

Now use the IDE code generator to create your first test:

@Test
public void shouldHaveCorrectInterfaces() {
    assertTrue("applicationProperties should be instance of ApplicationProperties",
            applicationProperties instanceof ApplicationProperties);
    assertTrue("metricsInfo should be instance of MetricsInfo",
            metricsInfo instanceof MetricsInfo);
}

The following imports are added:

import org.junit.Test;
import static org.junit.Assert.assertTrue;

This test case simply verifies that the parameters passed to the test class constructor conform to the expected type, proving that you have wired everything up correctly. To run the test, you need to make sure that the local Jira server is not running (type CTRL-D into its console if it is still up, and wait up to a minute for it to shut down). Then, at the root of your workspace, enter the command atlas-integration-test.

Wired Tests 2

Non-trivial wired test

Assuming that the test passes, add a second test. First define a constant just below the opening brace of the MetricsInfoImplWiredTest class:

final static String userName      = "admin";

Below the first test case, add the second:

@Test
public void shouldReturnProjectLead() {
    final Map contextMap = metricsInfo.getContextMap(null, null);
    final Project project = (Project) contextMap.get(MetricsInfo.PROJECT);
    assertNotNull("Project project should not be null", project);
    assertEquals("Project Lead Name", userName, project.getLeadUserName());
}

The assertTrue import is deleted and the following imports are added:

import com.atlassian.jira.project.Project;
import java.util.Map;
import static org.junit.Assert.*;

This test will fail: the context map is empty. To make the test pass, you will have to set up certain preconditions and pass the correct parameters to the getContextMap call. Firstly, you’ll have to get hold of the UserProjectHistoryManager object used by the module under test. So add a method to the MetricsInfo interface:

UserProjectHistoryManager getUserProjectHistoryManager();

The following import is added to the interface class’s source file:

import com.atlassian.jira.user.UserProjectHistoryManager;

Implement the method in MetricsInfoImpl.java:

@Override
public UserProjectHistoryManager getUserProjectHistoryManager() {
    return this.userProjectHistoryManager;
}

Next, the test class needs a few more constants:

final static String projectName   = "SampleProject";
final static String projectKey    = "SAM";
final static String projectType   = "business";

It also needs a few more fields:

private final Backdoor backdoor;
private final JIRAEnvironmentData environmentData;
private final UserManager userManager;
private final ApplicationUser applicationUser;
private final JiraHelper jiraHelper;
private final UserProjectHistoryManager userProjectHistoryManager;

These fields have to be initialised in the constructor (or inline):

this.environmentData       = new TestKitLocalEnvironmentData(); // localtest.properties supplies defaults
this.backdoor              = new Backdoor(environmentData);
this.userManager           = ComponentAccessor.getUserManager();
this.applicationUser       = userManager.getUserByName(userName);
this.jiraHelper            = new JiraHelper();
userProjectHistoryManager  = metricsInfo.getUserProjectHistoryManager();

The following imports are added:

import com.atlassian.jira.component.ComponentAccessor;
import com.atlassian.jira.plugin.webfragment.model.JiraHelper;
import com.atlassian.jira.testkit.client.Backdoor;
import com.atlassian.jira.testkit.client.JIRAEnvironmentData;
import com.atlassian.jira.testkit.client.util.TestKitLocalEnvironmentData;
import com.atlassian.jira.user.ApplicationUser;
import com.atlassian.jira.user.UserProjectHistoryManager;
import com.atlassian.jira.user.util.UserManager;

As the comment (above) says, you will have to create a few resources before this constructor can execute successfully.

src/test/resources/localtest.properties

jira.protocol = http
jira.host = localhost
jira.port = 2990
jira.context = /jira
jira.edition = all

jira.xml.data.location = ../../../test-classes/xml

src/test/resources/xml/.keepme

This file is empty – it merely ensures that the xml directory is committed to source code control.

src/test/resources/atlassian-plugin.xml

The plugin-tests plugin now requires more import-packages than its parent plugin. So add the following section to the plugin-info statement:

<bundle-instructions>
    <Import-Package>
        com.atlassian.jira.plugin.webfragment.model;resolution:="optional",
        com.atlassian.jira.user.util;resolution:="optional",
        com.atlassian.jira.util.collect;resolution:="optional",
        com.atlassian.jira.util;resolution:="optional",
        org.springframework.osgi.*;resolution:="optional",
        org.eclipse.gemini.blueprint.*;resolution:="optional",
        *;version="0";resolution:="optional"
    </Import-Package>
</bundle-instructions>

At this point, the test should still be failing for the same reason as before (because the project is null, not because of an error thrown in the constructor).

You’re now equipped to initialise all the required bits and pieces to enable the test to pass. Add before and after functions for your tests like this:

@Before
public void setup() {
    deleteSampleData();
    /* final long projectId = */ backdoor.project().addProject(
            projectName, projectKey, userName, projectType);
    final Project project = ComponentAccessor.getProjectManager()
            .getProjectByCurrentKey(projectKey);
    userProjectHistoryManager.addProjectToHistory(applicationUser, project);
}

@After
public void cleanup() {
    // Comment out this line if you want sample data to remain in the system after the tests have run
    deleteSampleData();
}

private void deleteSampleData() {
    try {
        backdoor.project().deleteProject(projectKey);
    } catch (Throwable e) {
        // ignore
    }
}

The following imports are added:

import org.junit.After;
import org.junit.Before;

The test should now pass.

NB (don’t do this now, but note for future use): if you add a constructor argument of type MyPluginComponent as well, you can call its public methods to provide additional help with the setup and teardown functions. You can also use the ProjectManager instance returned by the ComponentAccessor to create and delete project categories, issue types and more, which the backdoor will then be able to add to projects. For example (assuming you have declared constant strings for categoryName, categoryDesc, testIssueType and testIssue):

final ProjectManager projectManager = ComponentAccessor.getProjectManager();
final ProjectCategory projectCategory = projectManager.createProjectCategory(categoryName, categoryDesc);
final long projectCategoryId = projectCategory.getId();
backdoor.project().setProjectCategory(projectId, projectCategoryId);
final IssueTypeControl.IssueType issueType = backdoor.issueType().createIssueType(testIssueType);
backdoor.issues().createIssue(projectKey, testIssue, userName, "2", testIssueType);

If you add issue types and project categories in the setup method, remember to remove them in the teardown method – or in this example, in deleteSampleData().

Accelerate the Cycle

Speeding up Plugin Testing

In order to avoid the need to re-start the entire application server for each integration test cycle, I developed a plugin for Jira 7 that allows the old FastDev test console to be used with the new QuickReload feature. This is based on a suggestion from Christophe Merlotti.

Just insert the following under the existing pluginArtifact in the POM:

<pluginArtifact>
    <groupId>com.zuhlke.testing</groupId>
    <artifactId>atlassian-plugin-test-console-enabler</artifactId>
    <version>1.0.1</version>
</pluginArtifact>

The artifact is now in Maven Central. To use it, simply start the application server using atlas-run or atlas-debug and point your browser at http://localhost:2990/jira/plugins/servlet/it-test-console. Note that this supports plugin (wired) tests only; the unit tests should be run from the command line using atlas-package or equivalent, or from within your IDE.

Atlassian Plugin Test Console after wired tests have run
Contact person for United Kingdom

Immo Huneke

Expert Software Engineer

Immo Huneke has been with Zühlke since February 2004. Since completing a BSc in Physics and later qualifying as CEng MBCS CITP, he has gathered wide-ranging experiences in the fields of telecoms, finance, industrial manufacturing (including medical devices) and transport, among others. His passion is agile object-oriented software development and in the continuous improvement of the methods, techniques and tools used to achieve it.

Contact
Thank you for your message.