I recently started writing a JIRA plugin for Nuxeo and thought I would share some thoughts about the process.
First, let me talk about why a JIRA Plugin. We recently started using segment.io. It’s an analytics tool to rule them all (sound familiar?). Basically you replace all your different analytics tools (Google Analytics, Marketo, etc.) with segment.io. It lets you record what you want and then forward it to other existing tools (again, Google Analytics, Marketo, etc.).
We use JIRA for our support tickets. We wanted to get some stats about them so we started using segment.io in JIRA. So you get the idea, I needed to put a segment.io tracker on JIRA - which is basically putting some JavaScript on every JIRA page.
But contrary to Confluence, you can’t just put some custom code in every page header from the administration tab. It’s a little more complicated than that. You can either override a JSP template directly on a running JIRA instance (….) or you can build a plugin. I chose the plugin approach and I’m going to share this with you today.
The Atlassian SDK
The first thing to do when you want to create a plugin for any Atlassian product is download their SDK. What’s in the SDK you ask? It contains Apache Maven, a pre-filled Maven repository and a set of scripts. Those scripts wrap many maven commands to hide some of the complexity and give a consistent experience. And that’s all. I like how they use standard technologies but made it easier for newcomers thanks to their custom scripts. If you are a maven guru you probably don’t need them. But…
Setting up the SDK is easy, you just have to put the scripts in your PATH environment variable. Then you have commands to easily create some plugin projects (probably based on maven archetype) like atlas-create-jira-plugin-module
. This will generate the skeleton of the plugin. Next, go into this plugin folder and run atlas-run
. This will start a JIRA development server instance with the generated plugin already deployed. You can verify this easily by going to the administration interface.
The next logical step is to import this code into your favorite IDE. I personally use Eclipse most of the time. Usually I run mvn eclipse:eclipse
to generate the files needed by Eclipse, but here I need to use the Maven embedded by the SDK. You can use the atlas-mvn
command for that. It’s just like the usual mvn
but using the version from the SDK.
Now the code is in Eclipse, I can start coding.
A Simple Plugin
Again what I want to do is have some JavaScript in the header of every JIRA page to use segment.io. To do so I need to add what Atlassian calls a Web Panel. It’s essentially a template rendered in a predefined location. I could not find an exhaustive list of locations but when I browsed JIRA’s code (that was in the target folder of my plugin thanks to the atlas-run
command), I found the atl.header.after.scripts
which was exactly what I needed. I want to add my Javascript code after the regular Javascripts because I am going to use jQuery.
Then I created a Velocity template in /templates/segmentio/segmentio-tracker.vm
. For all Web Panel
you declare, you can associate a Context Provider. This is a Java Class implementing the ContextProvider
interface and responsible for filling the velocity context of your panel.
All this information needs to be declared in a web-panel
tag in the atlassian-plugin.xml
file. This is the descriptor of your module, the main entry point of all the extensions/modifications you can do to an Atlassian product. Here’s how my first descriptor looked:
<?xml version="1.0" encoding="UTF-8"?>
<atlassian-plugin key="${project.groupId}.${project.artifactId}"
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>
<web-panel name="segment.io" i18n-name-key="segment-.io.name"
key="segment-.io" location="atl.header.after.scripts" weight="1000">
<context-provider class="com.nuxeo.segmentio.SegmentIOTracker" />
<resource name="view" type="velocity" location="/templates/segmentio/segmentio-tracker.vm" />
</web-panel>
</atlassian-plugin>
At this point my context provider is not doing anything. And my template is simply holding the default JavaScript from the Segment.io documentation. Nothing crazy. But since I went to the trouble of writing an actual plugin, I thought I would make more of it and make it configurable/reusable. My goal has changed and is now to add a panel in the Administration interface to let the administrator give his Segment.io API key and choose if he wants to count user logins or not. So I need to figure out how to add a link and a panel in the Administration interface.
Links, a bit like actions in Nuxeo, are represented by what they call a Web Item
. The most import concepts of Web Items are the section
and the weight
. The section
is the future placement of your link, like categories for actions in Nuxeo, and the weight
defines the order of the links in the same section
. And of course you have the link tag which defines where the web item should link to. Here, it goes to SegmentIOConfigAction.jspa. It’s actually an alias defined in the segmentIOConfigAction webwork configuration.
A webwork defines a URL-addressible ‘action’, allowing JIRA’s user-visible functionality to be extended or partially overridden.
To keep on the Nuxeo analogy, it’s the seam bean backing my XHTML template. Except here it’s a simple class and injection is handled in the constructor using Spring. This class will handle the /templates/segmentio/config.vm
velocity template. To persist the information retrieved by the webwork
, I’ll create a new SegmentIOConfig service using the component
tag. The goal of this service is to store my plugin configuration. And JIRA already has a service called PluginSettingsFactory that will help on this matter. So to make sure I’ll have this PluginSettingsFactory service, I’ll add it to my descriptor thanks to the component-import
tag.
In the end, after all this wiring, my atlassian-plugin.xml
file looks like this:
<!-- Declare my new configuration service to hold the conf parameters--> <component key="segmentioService" name="SegmentIO Configuration Service" class="com.nuxeo.segmentio.config.SegmentIOConfig" />
<!-- Import the PluginSettingsFactory to make sure it's available in my service. --> <component-import key="pluginSettingsFactory" interface="com.atlassian.sal.api.pluginsettings.PluginSettingsFactory" />
<!-- Creates a new link in the Jira Administration interface --> <web-item key="segmentIOConfigActionLink" section="admin_plugins_menu/integrations_section" i18n-name-key="com.nuxeo.segmentio.config.adminLink" name="Configure SegmentIO" weight="1"> <label key="com.nuxeo.segmentio.config.adminLink" /> <link linkId="segmentIoActionLink">/secure/admin/SegmentIOConfigAction.jspa</link> </web-item>
<!-- Declare the configuration form used to retrieve segment.io parameters --> <webwork1 key="segmentIOConfigAction" name="SegmentIO Config Action"> <actions> <action name="com.nuxeo.segmentio.config.SegmentIOConfigAction" alias="SegmentIOConfigAction"> <view name="success">/templates/segmentio/config.vm</view> <view name="input">/templates/segmentio/config.vm</view> </action> </actions> </webwork1> <!-- the internationalization resources --> <resource type="i18n" name="i18n" location="i18n.messages" />
On to the actual coding part! Let’s start by explaining the SegmentIOConfig, responsible for the persistence of the configuration options. It’s a very simple class. Notice that the final PluginSettingsFactory field is instantiated automatically through the constructor. This works because of the component-import
tag of the descriptor. Then I simply added a getter and a setter for each element of my configuration: the segment.io API key and a boolean value to activate user login tracking. The PluginSettingsFactory service takes care of everything as you can see in the source code:
package com.nuxeo.segmentio.config;
import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory;
public class SegmentIOConfig {
final PluginSettingsFactory pluginSettingsFactory;
String SEGMENT_IO_CONFIG_KEY = "com.nuxeo.segmentio.config.apikey";
String SEGMENT_IO_CONFIG_TRACK_LOGIN = "com.nuxeo.segmentio.config.trackLogin";
public SegmentIOConfig(PluginSettingsFactory pluginSettingsFactory) { this.pluginSettingsFactory = pluginSettingsFactory; }
public void storeSegmentIOKey(String value) { pluginSettingsFactory.createGlobalSettings().put(SEGMENT_IO_CONFIG_KEY, value); }
public String getSegmentIOKey() { Object apiKey = pluginSettingsFactory.createGlobalSettings().get( SEGMENT_IO_CONFIG_KEY); if (apiKey != null && apiKey instanceof String) { return (String) apiKey; } else { return null; } }
public void storeTrackLogin(boolean trackLogin) { if (trackLogin) { pluginSettingsFactory.createGlobalSettings().put( SEGMENT_IO_CONFIG_TRACK_LOGIN, "true"); } else { pluginSettingsFactory.createGlobalSettings().put( SEGMENT_IO_CONFIG_TRACK_LOGIN, "false"); } }
public Boolean getTrackLogin() { return Boolean.valueOf((String) pluginSettingsFactory .createGlobalSettings().get(SEGMENT_IO_CONFIG_TRACK_LOGIN)); } }
About the WebWork backing class; the constructor has a SegmentIOConfig instance as parameter. Again this works because of the component
tag of the descriptor. The wiring is made by Spring. The apiKey, trackLogin and trackLoginSelect fields are in the config.vm
template. The doExecute method does not do anything except return success. It’s called when the template is displayed after the link has been clicked. The doUpdate on the other hand, is called when the user clicks on the Save button of the form. The code is very simple as you can see:
package com.nuxeo.segmentio.config;
import com.atlassian.jira.web.action.JiraWebActionSupport;
public class SegmentIOConfigAction extends JiraWebActionSupport { private SegmentIOConfig config; private String apiKey; private boolean trackLogin; private String[] trackLoginSelect;
public SegmentIOConfigAction(SegmentIOConfig config) { this.config = config; this.apiKey = config.getSegmentIOKey(); this.trackLogin = config.getTrackLogin(); }
@Override protected String doExecute() throws Exception { return SUCCESS; }
public String doUpdate() { config.storeSegmentIOKey(apiKey); if (trackLoginSelect != null ) { trackLogin = true; } else { trackLogin = false; } config.storeTrackLogin(trackLogin); return getRedirect("SegmentIOConfigAction.jspa"); }
public String getApiKey() { return apiKey; }
public void setApiKey(String apiKey) { this.apiKey = apiKey; }
public String[] getTrackLoginSelect() { return trackLoginSelect; }
public void setTrackLoginSelect(String[] trackLoginSelect) { this.trackLoginSelect = trackLoginSelect; }
public boolean isTrackLogin() { return trackLogin; }
public void setTrackLogin(boolean trackLogin) { this.trackLogin = trackLogin; }
}
Here’s the Velocity (can’t say I am a fan, it feels so old…) template associated to the WebWork:
<html>
<head>
<title>$i18n.getText("com.nuxeo.segmentio.config.title")</title>
<meta name="decorator" content="atl.admin">
</head>
<body>
<table width="100%" cellspacing="0" cellpadding="10" border="0">
<tbody>
<tr>
<td>
<table class="jiraform maxWidth">
<tbody>
<tr>
<td class="jiraformheader">
<h3 class="formtitle">$i18n.getText("com.nuxeo.segmentio.config.title")</h3>
</td>
</tr>
<tr>
<td class="jiraformbody">
<p> $i18n.getText("com.nuxeo.segmentio.config.instructions")</p>
<form method="post" action="SegmentIOConfigAction!update.jspa">
<p>
<table>
<tr>
<td>$i18n.getText("com.nuxeo.segmentio.config.apiKeyCell")</td>
<td>
<input type="text" name="apiKey" #if ($!apiKey) value="$apiKey" #end />
</td>
</tr>
<tr>
<td>$i18n.getText("com.nuxeo.segmentio.config.trackLoginCell")</td>
<td>
<input type="checkbox" name="trackLoginSelect" id="trackLoginSelect" #if ($trackLogin) checked='checked' #end/>
</td>
</tr>
<tr>
<td colspan="2">
<input type="submit" value="$i18n.getText('com.nuxeo.segmentio.config.applyButton')">
</td>
</tr>
</table>
</p>
</form>
</td>
</tr>
</tbody>
</table>
<p></p>
</td>
</tr>
</tbody>
</table>
</body>
</html>
Now with all of this I have an administration panel for my plugin:
It’s good to have all this information but I need to put them in my header template. To make them available, I need to use the ContextProvider
associated to the template. It’s a simple Java class implementing the ContextProvider
interface. Again the constructor is used to ‘inject’ SegmentIOConfig
and JiraAuthenticationContext
. The latest will give information on the current logged in user. Everything I needed it added to a Map returned by the getContextMap
method. That’s the one I’ll be able to use in my Velocity template.
package com.nuxeo.segmentio;
import java.util.Map;
import com.atlassian.jira.component.ComponentAccessor; import com.atlassian.jira.config.properties.APKeys; import com.atlassian.jira.config.properties.ApplicationProperties; import com.atlassian.jira.security.JiraAuthenticationContext; import com.atlassian.jira.user.ApplicationUser; import com.atlassian.jira.util.JiraVelocityUtils; import com.atlassian.jira.util.collect.MapBuilder; import com.atlassian.plugin.PluginParseException; import com.atlassian.plugin.web.ContextProvider; import com.nuxeo.segmentio.config.SegmentIOConfig;
public class SegmentIOTracker implements ContextProvider {
private final JiraAuthenticationContext authenticationContext;
private final SegmentIOConfig segmentIOConfig;
private Map<String, String> params;
public SegmentIOTracker(JiraAuthenticationContext authenticationContext, SegmentIOConfig segmentIOConfig) { this.authenticationContext = authenticationContext; this.segmentIOConfig = segmentIOConfig; }
@Override public void init(Map<String, String> params) throws PluginParseException { this.params = params; }
@Override public Map<String, Object> getContextMap(Map<String, Object> context) { final MapBuilder<String, Object> paramsBuilder = MapBuilder .newBuilder(JiraVelocityUtils.getDefaultVelocityParams(context, authenticationContext)); paramsBuilder.addAll(params); Boolean trackLogin = segmentIOConfig.getTrackLogin(); if (trackLogin) { ApplicationUser user = authenticationContext.getUser(); if (user != null) { paramsBuilder.add("username", user.getUsername()); paramsBuilder.add("name", user.getDisplayName()); paramsBuilder.add("email", user.getEmailAddress()); } } ApplicationProperties applicationProperties = ComponentAccessor .getApplicationProperties(); String baseUrl = applicationProperties.getString(APKeys.JIRA_BASEURL); paramsBuilder.add("baseUrl", baseUrl); paramsBuilder.add("segmentIOKey", segmentIOConfig.getSegmentIOKey()); return paramsBuilder.toMap(); }
}
And here it is, finally, the template to add segment.io to JIRA. It starts with a null or empty test on the segmentIOKey value. If a key has been provided, than the JavaScript library can be initiated. If the current page is an issue, than the page method is called with the project key as first argument. The first argument in the JavaScript page method is actually treated as a category. The second argument is the key and the title of the issue.
Then if the trackLogin
box has been checked, the current user, if there is one, is identified and we send a Login event to segment.io if he just logged in.
#if( $!segmentIOKey != "") <script type="text/javascript"> window.analytics || (window.analytics = []); window.analytics.methods = ['identify', 'track', 'trackLink', 'trackForm', 'trackClick', 'trackSubmit', 'page', 'pageview', 'ab', 'alias', 'ready', 'group', 'on', 'once', 'off']; window.analytics.factory = function (method) { return function () { var args = Array.prototype.slice.call(arguments); args.unshift(method); window.analytics.push(args); return window.analytics; }; };
for (var i = 0; i < window.analytics.methods.length; i++) { var method = window.analytics.methods[i]; window.analytics[method] = window.analytics.factory(method); }
window.analytics.load = function (apiKey) { var script = document.createElement('script'); script.type = 'text/javascript'; script.async = true; script.src = ('https:' === document.location.protocol ? 'https://' : 'http://') + 'd2dq2ahtl5zl1z.cloudfront.net/analytics.js/v1/' + apiKey + '/analytics.min.js';
// Find the first script element on the page and insert our script next to it. var firstScript = document.getElementsByTagName('script')[0]; firstScript.parentNode.insertBefore(script, firstScript); };
window.analytics.SNIPPET_VERSION = '2.0.8'; window.analytics.load('$segmentIOKey');
var key = jQuery('#key-val').attr("data-issue-key"); if (key) { var projectKey = key.split("-")[0]; var summary = jQuery("#summary-val").text(); window.analytics.page(projectKey, key + " - " + summary); } else { window.analytics.page(); }
#if( $trackLogin ) if ('$baseUrl'.indexOf(document.location.host) != -1) { if ((document.referrer.indexOf("login.xml") != -1 )|| (document.referrer.indexOf("login.jsp") != -1)) { window.analytics.identify('$email', { email: '$email', jira_username: '$username', jira_name: '$name', jira_last_login: Date.now() }); window.analytics.track('Jira Login'); } } #end
</script> #end
The full source code of the plugin is available on GitHub.