Send Anything, Handle Anything: Navigating the Power of Flexible JIRA Synchronization

adminUncategorized

My name is Serhiy, a member of the Exalate development team. In this blog post, I’ll take you on a journey through our discoveries of Exalate’s scripting powers and offer insights on how to approach scripting support responsibly. So, let’s dive in and explore the world of flexible JIRA synchronization with Exalate.

The Problem and the Story

By design, our product allows administrators to configure the rules for Issue Tracker interaction using Groovy scripts.

Right from the start, we wanted the user to rock. The Admin should be able to define messages of any content, regardless of how complex creating them might be. One doesn’t configure the “synchronization” of issues. Instead, one rule is “What should our JIRA say to another JIRA when an event happens to an issue”.

So there we were. Our intent was to allow to write conditionals, checks, filters – all that stuff that claims “send this, only if that happens”:

 

import static java.util.concurrent.TimeUnit.DAYS
import java.util.Date
  
replica.customKeys."More than 10 votes for this issue" = (issue.votes > 10)
def difference = (new Date()).time - issue.customFields."Actual Create Date".value.time
if (difference < DAYS.toMillis(356)) {
    //it's more than a year since the issue has been reported
    replica.customKeys."More than a year" = true
}

 

Such that the other JIRA Administrator could use the replica.customKeys. “More than 10 votes for this issue” and replica.customKeys. “More than a year”:

 

if (replica.customKeys."More than 10 votes for this issue" && 
    !(replica.customKeys."More than a year")) {
    issue.doTranistion = "Waiting For Review"
}

 

Then we can help the user to write such decisions by providing helper methods:

 

import java.util.Date
  
def now = (new Date())
replica.customKeys."No more versions" = nodeHelper
  .getProject("FOO")
  .versions
  .findAll {(it.releaseDate?.time ?: 0) > now.time} 
  // find all the versions which 
  // have release dates in the future
  .isEmpty                                          
  // if there are no versions with 
  // release date in the future => No more versions

 

Here’s the problem: as long as we want to provide more and more ways to get information about JIRA, we need to write more and more methods that would copy existing JIRA API.
Which is neat. However, it was limited to only all the methods, that we provide to our helpers.

 

Enter the Power

In order for the script to be able to do things like new Date() the classpath for the script should contain the class java.util.Date. Obviously, this is something that any Groovy application would have.

We included our classes in the script. Did that on purpose, so that the compiler would help to identify simple problems like method name mistyping. This allows:

 

import com.exalate.api.domain.hubobject.v1_2.IHubIssue
  
IHubIssue r = replica
r.foo // produces a compiler error, since 
      // there is no such field as "foo" on the IHubIssue

 

Ok, so we have all the classes from our plugin available in the script, so what?

 

You can do anything #1 
(provided that JIRA API allows it)

JIRA API has this class com.atlassian.jira.component.ComponentAccessor that allows to get any service, registered in the JIRA application. I’m not afraid to emphasis: all classes from all the plugins and the JIRA itself.

import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.IssueManager
  
IssueManager im = ComponentAccessor.getIssueManager()
im.deleteIssueNoEvent(im.getIssueObject("FOO-1"))

 

This would just get issue FOO-1 and delete it. If we wouldn’t have the ComponentAccessor, the Exalate team would have to write a helper method to allow administrators to do this, along with all the possible flavors of it:

  • deleteIssue(User user, Issue issue, EventDispatchOption eventDispatchOption, boolean sendMail)
  • delete(User user, DeleteValidationResult issueValidationResult)
  • delete(User user, DeleteValidationResult issueValidationResult, EventDispatchOption eventDispatchOption, boolean sendMail)

 

You can do anything #2
(provided you can find it on the Internet)

OK, but what if an admin on JIRA A needs to send some info, to JIRA B, which is not available in the JIRA itself?

Provided that Exalate processors’ classpath contains all the classes of Exalate, the scripts would do anything that Exalate can. Go REST client power!

 

import groovy.json.JsonSlurper
 
def getNfeedValue =  {
  String issueKey,  customFieldId ->
   
  URL url;
  def baseURL = "https://foo.exalate.net/rest/nfeed/1.0/customfield";
 
  
  String userpass = "user:password";
  String basicAuth = "Basic " + new String(new Base64().encoder.encode(userpass.getBytes()));
 
 
  url = new URL(baseURL + "/${issueKey}/${customFieldId}/EXPORT");
  URLConnection connection = url.openConnection();
  connection.requestMethod = "GET"
  connection.setRequestProperty ("Content-Type", "application/json;charset=UTF-8")
  connection.setRequestProperty ("Authorization", basicAuth);
  connection.connect();
 
  String jsonString = new Scanner(connection.getContent(),"UTF-8").useDelimiter("\A").next();
  def jsonSlurper = new JsonSlurper()
  def jsonObject = jsonSlurper.parseText(jsonString)
  return jsonObject.displays
   
}
 
replica.customKeys.Products = getNfeedValue(issue.key, "customfield_10302")

 

You can do anything #3 
(that JIRA DB has)

OK, this means anything reachable with JIRA API and REST APIs all over the world. But how about the information, available in the JIRA, but not accessible via an API?

import com.atlassian.jira.ofbiz.DefaultOfBizConnectionFactory;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.sql.Statement;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.google.common.base.Function;
import com.google.common.collect.Lists;
  
getAllTableNamesWithPrefix = { prefix ->
    try (Connection connection = (new DefaultOfBizConnectionFactory()).getConnection()) {
        DatabaseMetaData metaData = connection.getMetaData();
        ResultSet tablesRs = metaData.getTables(null, null, prefix+"%", null);
        List<String> tableNames = Lists.newLinkedList();
        while (tablesRs.next()) {
            String name = tablesRs.getString(3);
            String type = tablesRs.getString(4);
            if ("TABLE".equals(type)) {
                tableNames.add(name);
            }
        }
        return tableNames;
    } catch (Exception e) {
        LOG.error("Failed to get table names due to a non-sql error", e);
        return Collections.emptyList();
    }
}

 

We’ve just queried the JIRA database with plain jdbc.

 

You can do anything #4
(that all JIRA add-ons offer)

Use another plugin’s classes? Easy:

import com.atlassian.crowd.embedded.api.User
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.Issue
import com.atlassian.jira.issue.IssueManager
import com.atlassian.jira.security.JiraAuthenticationContext
import com.atlassian.plugin.PluginAccessor
import com.atlassian.jira.user.ApplicationUser
import com.atlassian.jira.component.ComponentAccessor
 
    
// get an issue
IssueManager issueManager = ComponentAccessor.getOSGiComponentInstanceOfType(IssueManager.class);
Issue issue = issueManager.getIssueObject("TEST-1");
    
// find TGE custom fields
PluginAccessor pluginAccessor = ComponentAccessor.getPluginAccessor();
Class tgeConfigManagerClass = pluginAccessor.getClassLoader().findClass("com.idalko.jira.plugins.igrid.api.config.grid.TGEGridConfigManager");
def tgeConfigManager = ComponentAccessor.getOSGiComponentInstanceOfType(tgeConfigManagerClass);
List<Long> tgeCustomFieldIds = tgeConfigManager.getGridCustomFieldIds();
    
// get current user
JiraAuthenticationContext jiraAuthenticationContext = ComponentAccessor.getOSGiComponentInstanceOfType(JiraAuthenticationContext.class);
Object userObject = jiraAuthenticationContext.getLoggedInUser();
User user = userObject instanceof ApplicationUser ? ((ApplicationUser) userObject).getDirectoryUser() : (User) userObject
    
// read the grid data
Class dataManagerClass = pluginAccessor.getClassLoader().findClass("com.idalko.jira.plugins.igrid.api.data.TGEGridTableDataManager");
def tgeGridDataManager = ComponentAccessor.getOSGiComponentInstanceOfType(dataManagerClass);
    
StringBuilder result = new StringBuilder();
for (Long tgeCustomFieldId : tgeCustomFieldIds) {
    try {
        def callResult = tgeGridDataManager.readGridData(issue.getId(), tgeCustomFieldId, null, null, 0, 10, user);
        result.append("Grid ID=" + tgeCustomFieldId + " content: " + callResult.getValues() + "
");
    } catch (Exception e) {
        result.append("Grid ID=" + tgeCustomFieldId + " data cannot be retrieved: " + e.getMessage() + "
");
    }
}
    
println(result.toString());
replica.customKeys.gridData = result

 

Conclusion

You can do anything with flexible JIRA synchronization.
But remember, that with great power comes great responsibility.

When you introduce scripting support, make sure, you know:

  • What’s available in the script’s classpath
  • Who can access the scripting
  • What do you actually want the users to be able to do

 

tumblr_ljnm8p2UU51qj3mylo1_500.gif

 

Allow your administrators to kick ass!