Smart Draw

The following will walk you through the important pieces of a script which uses ScriptRunner for Jira to create a Confluence SmartDraw chart representing issue hierarchy. At a high level, this script executes a JQL query against your Jira instance, returns the results, builds SmartDraw Object Notation (SDON) using the data, finds any hierarchy (Epics, Sub-Tasks, etc) and creates an SDON file. This SDON file is sent to a Confluence page and when you update the SmartDraw macro, you can reference the SDON file and generate your hierarchical Jira issue chart.

Click here to jump to the finished example.

Jira issues and Customization

Customizing the chart

The Confluence page ID, the Confluence attachment ID, the JQL query and the colors for the hierarchical chart shapes are all stored and easily edited in this section. Don’t worry about the Confluence base URL — this will be determined later on by the application link.

The attachment and its ID must already exist prior to using this script.

If you add a new issuetype and color, you must also update the buildShape() method.

// variables for customizing the chart
def contentID = '2752514'        // confluence page id
def attachmentID = 'att2752516'  // coonfluence attachment id
def strQuery = 'project = NT'    // your JQL query
def colors = [
    root   : '#cccccc',  // gray
    epic   : '#db6776',  // red
    default: '#db9367',  // orange
    bug    : '#f47ac9',  // pink
    subtask: '#dbcd67'   // yellow
]

A list of issues

The JQL search results return Jira issue objects, but we want to simplify those objects into our own issues[] list. This will make the data easier to interact with down the road and allow us to store some other values within the list. Each JQL result (an issue object) is parsed and strings for its key, summary, parent (whether epic or standard issuetype) are stored as a map within our list. Then, we iterate back through the issues[] list and build our SDON "shapes" for each issue.

def applicationProperties = ScriptRunnerImpl.getPluginComponent(ApplicationProperties)
def baseURL = applicationProperties.getBaseUrl(UrlMode.ABSOLUTE)
def customFieldManager = ComponentAccessor.getCustomFieldManager()
def jqlQueryParser = ComponentAccessor.getComponent(JqlQueryParser)
def user = ComponentAccessor.getJiraAuthenticationContext().getUser()
def jqlQuery = jqlQueryParser.parseQuery(strQuery)
def jqlResults = getJqlResults(jqlQuery, user)

List<Issue> getJqlResults(Query query, ApplicationUser user) {
    def searchService = ComponentAccessor.getComponent(SearchService)
    def search = searchService.search(user, query, PagerFilter.getUnlimitedFilter())
    def results = search.results
    results
}

def issues = []
jqlResults.each { issue ->
    def key = issue.getKey()
    def summary = issue.getSummary()
    def issuetype = issue.getIssueType().getName()
    def epicField = issue.getCustomFieldValue(customFieldManager.getCustomFieldObjectByName("Epic Link"))
    def epic = epicField ? epicField.getKey() : null
    def parent = issue.getParentObject() ? issue.getParentObject().toString() : null
    parent = (epic != null) ? epic : parent
    def newMap = [summary: summary, issuetype: issuetype, key: key, parent: parent, shape: null]
    issues.push(newMap)
}

// add shapes to the main list
issues.each { it ->
    def newShape = buildShape(it, baseURL, colors)
    it.shape = newShape
}

Building these shapes now and storing them in the issues[] using the buildShape method. This method will build the shape, a critical piece of the SDON format. This shape correlates to a square on the SmartDraw diagram. These shapes will later be plugged into another SDON structure. The issuetype, key and summary are added to the shape label and shape color is determined based on issuetype.

Map<String, ?> buildShape(Map<String, String> element, String baseURL, Map<String, String> colors) {
    def key = element.key
    def issuetype = element.issuetype
    def summary = element.summary
    def shape = [:]
    shape.put('Label', "${key} / ${issuetype.toUpperCase()}: \n\n ${summary}")
    switch (issuetype) {
        case "Epic":
            shape.put('FillColor', colors.epic)
            break
        case "Bug":
            shape.put('FillColor', colors.bug)
            break
        case "Sub-task":
            shape.put('FillColor', colors.subtask)
            break
        default:
            shape.put('FillColor', colors.default)
            break
    }
    shape.put('Hyperlink', ['url': "${baseURL}/browse/${key}"])
    shape
}

SmartDraw Object Notation Structure

SDON & Root Shape

SmartDraw SDON API information (SDON) format requires a very specific structure, with an important concept being the shape. It begins with the RootShape. Eventually, all the other shapes that are built will be plugged into the RootShape — and eventually the RootShape will be plugged into the sdonMap.

def sdonMap = [:]
sdonMap.put('DiagramType', 'Orgchart')
def rootShapeMap = [:]
def rootLabel = "${strQuery}"
rootShapeMap.put('FillColor', colors.root)
rootShapeMap.put('Label', "JQL:\n${rootLabel}\nTotal issues: ${jqlResults.size()}")
rootShapeMap.put('Hyperlink', ["url": "${baseURL}/issues/?jql=${strQuery}"])
def shapes = []

Modifying the existing SDON

The issues[] is searched to find Epics, then their epic-linked issues, then those issues' sub-tasks. A shape list is built from the top down like this. The process is then repeated, minus the Epics, for the standard issuetypes with sub-tasks. All of the new shapes are added to the shapeListArray and eventually the sdonMap (by way of the RootShape).

// find epics, their epic-linked issues and those issues' sub-tasks
issues.findAll { it -> it.issuetype == "Epic" }.each { epicIssue ->
    def pShapes = []
    issues.findAll { zit -> zit.parent == epicIssue.key }.each { storyIssue ->
        def subShapes = []
        issues.findAll { yit -> yit.parent == storyIssue.key }.each { subIssue ->
            subShapes.push(subIssue.shape)
            storyIssue.shape.put('ShapeList', [['Shapes': subShapes]])
        }
        pShapes.push(storyIssue.shape)
        epicIssue.shape.put('ShapeList', [['Shapes': pShapes]])
    }
    shapes.push(epicIssue.shape)
}

// find issues without epics and those issues' sub-tasks
issues.findAll { it -> it.parent == null && it.issuetype != "Epic" }.each { nonEpicIssue ->
    def pShapes = []
    issues.findAll { zit -> zit.parent == nonEpicIssue.key }.each { subIssue ->
        pShapes.push(subIssue.shape)
        nonEpicIssue.shape.put('ShapeList', [['Shapes': pShapes]])
    }
    shapes.push(nonEpicIssue.shape)
}

rootShapeMap.put('ShapeList', [['Shapes': shapes]])
sdonMap.put('RootShape', [rootShapeMap])

Confirming a Confluence application link exists

The Confluence application link is tested to see if it exists. If no application link exists, the script would obviously fail.

def confluenceLink = getPrimaryConfluenceLink()
log.debug "Confluence link " + (confluenceLink ? "exists" : "does not exist")
assert confluenceLink: 'must have a working app link set up'
def authenticatedRequestFactory = confluenceLink.createAuthenticatedRequestFactory()

Creating the .SDON file

An attempt is made to create a temporary SDON file using the sdonMap (a collection of maps, lists) after it is converted to a string.

def sdonDocument = new JsonBuilder(sdonMap).toString()

File f = Files.createTempFile("jira", ".sdon").toFile()
log.debug "Temp SDON file created at ${f.getAbsolutePath()}"

try {
    f.write(sdonDocument)
} catch (IOException e) {
    e.printStackTrace()
}

Confluence UI

Attaching the SDON file to the Confluence Page

The file is attached to the Confluence page. The content (page) ID, the attachment ID and the temp SDON file created are referenced on this step.

authenticatedRequestFactory
    .createRequest(Request.MethodType.POST, "/rest/api/content/${contentID}/child/attachment/${attachmentID}/data")
    .addHeader("X-Atlassian-Token", "nocheck")
    .setFiles([new RequestFilePart(f, 'file')])
    .execute(new ResponseHandler<Response>() {
        @Override
        void handle(Response response) throws ResponseException {
            if (response.statusCode != HttpURLConnection.HTTP_OK) {
                throw new Exception(response.getResponseBodyAsString())
            }
        }
    })

Confluence and SmartDraw macro

If the attachment is successfully updated with the new SDON data, you can add or update the SmartDraw chart.

  1. Navigate to your page

  2. Edit the page

  3. Insert a macro

  4. Select the SmartDraw Diagram

  5. Choose your SDON file in the top-left

Complete script

import com.atlassian.applinks.api.ApplicationLink
import com.atlassian.applinks.api.ApplicationLinkService
import com.atlassian.applinks.api.application.confluence.ConfluenceApplicationType
import com.atlassian.jira.bc.issue.search.SearchService
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.issue.Issue
import com.atlassian.jira.jql.parser.JqlQueryParser
import com.atlassian.jira.user.ApplicationUser
import com.atlassian.jira.web.bean.PagerFilter
import com.atlassian.query.Query
import com.atlassian.sal.api.component.ComponentLocator
import com.atlassian.sal.api.net.Request
import com.atlassian.sal.api.net.RequestFilePart
import com.atlassian.sal.api.net.Response
import com.atlassian.sal.api.net.ResponseException
import com.atlassian.sal.api.net.ResponseHandler
import groovy.json.JsonBuilder
import com.atlassian.sal.api.ApplicationProperties
import com.atlassian.sal.api.UrlMode
import com.onresolve.scriptrunner.runner.ScriptRunnerImpl
import java.nio.file.Files

// variables for customizing the chart
def contentID = '2752514'        // confluence page id
def attachmentID = 'att2752516'  // coonfluence attachment id
def strQuery = 'project = NT'    // your JQL query
def colors = [
    root   : '#cccccc',  // gray
    epic   : '#db6776',  // red
    default: '#db9367',  // orange
    bug    : '#f47ac9',  // pink
    subtask: '#dbcd67'   // yellow
]

def applicationProperties = ScriptRunnerImpl.getPluginComponent(ApplicationProperties)
def baseURL = applicationProperties.getBaseUrl(UrlMode.ABSOLUTE)
def customFieldManager = ComponentAccessor.getCustomFieldManager()
def jqlQueryParser = ComponentAccessor.getComponent(JqlQueryParser)
def user = ComponentAccessor.getJiraAuthenticationContext().getUser()
def jqlQuery = jqlQueryParser.parseQuery(strQuery)
def jqlResults = getJqlResults(jqlQuery, user)

def issues = []
jqlResults.each { issue ->
    def key = issue.getKey()
    def summary = issue.getSummary()
    def issuetype = issue.getIssueType().getName()
    def epicField = issue.getCustomFieldValue(customFieldManager.getCustomFieldObjectByName("Epic Link"))
    def epic = epicField ? epicField.getKey() : null
    def parent = issue.getParentObject() ? issue.getParentObject().toString() : null
    parent = (epic != null) ? epic : parent
    def newMap = [summary: summary, issuetype: issuetype, key: key, parent: parent, shape: null]
    issues.push(newMap)
}

// add shapes to the main list
issues.each { it ->
    def newShape = buildShape(it, baseURL, colors)
    it.shape = newShape
}

def sdonMap = [:]
sdonMap.put('DiagramType', 'Orgchart')
def rootShapeMap = [:]
def rootLabel = "${strQuery}"
rootShapeMap.put('FillColor', colors.root)
rootShapeMap.put('Label', "JQL:\n${rootLabel}\nTotal issues: ${jqlResults.size()}")
rootShapeMap.put('Hyperlink', ["url": "${baseURL}/issues/?jql=${strQuery}"])
def shapes = []

// find epics, their epic-linked issues and those issues' sub-tasks
issues.findAll { it -> it.issuetype == "Epic" }.each { epicIssue ->
    def pShapes = []
    issues.findAll { zit -> zit.parent == epicIssue.key }.each { storyIssue ->
        def subShapes = []
        issues.findAll { yit -> yit.parent == storyIssue.key }.each { subIssue ->
            subShapes.push(subIssue.shape)
            storyIssue.shape.put('ShapeList', [['Shapes': subShapes]])
        }
        pShapes.push(storyIssue.shape)
        epicIssue.shape.put('ShapeList', [['Shapes': pShapes]])
    }
    shapes.push(epicIssue.shape)
}

// find issues without epics and those issues' sub-tasks
issues.findAll { it -> it.parent == null && it.issuetype != "Epic" }.each { nonEpicIssue ->
    def pShapes = []
    issues.findAll { zit -> zit.parent == nonEpicIssue.key }.each { subIssue ->
        pShapes.push(subIssue.shape)
        nonEpicIssue.shape.put('ShapeList', [['Shapes': pShapes]])
    }
    shapes.push(nonEpicIssue.shape)
}

rootShapeMap.put('ShapeList', [['Shapes': shapes]])
sdonMap.put('RootShape', [rootShapeMap])

def confluenceLink = getPrimaryConfluenceLink()
log.debug "Confluence link " + (confluenceLink ? "exists" : "does not exist")
assert confluenceLink: 'must have a working app link set up'
def authenticatedRequestFactory = confluenceLink.createAuthenticatedRequestFactory()

def sdonDocument = new JsonBuilder(sdonMap).toString()

File f = Files.createTempFile("jira", ".sdon").toFile()
log.debug "Temp SDON file created at ${f.getAbsolutePath()}"

try {
    f.write(sdonDocument)
} catch (IOException e) {
    e.printStackTrace()
}

authenticatedRequestFactory
    .createRequest(Request.MethodType.POST, "/rest/api/content/${contentID}/child/attachment/${attachmentID}/data")
    .addHeader("X-Atlassian-Token", "nocheck")
    .setFiles([new RequestFilePart(f, 'file')])
    .execute(new ResponseHandler<Response>() {
        @Override
        void handle(Response response) throws ResponseException {
            if (response.statusCode != HttpURLConnection.HTTP_OK) {
                throw new Exception(response.getResponseBodyAsString())
            }
        }
    })

f.delete()

issues

List<Issue> getJqlResults(Query query, ApplicationUser user) {
    def searchService = ComponentAccessor.getComponent(SearchService)
    def search = searchService.search(user, query, PagerFilter.getUnlimitedFilter())
    def results = search.results
    results
}

Map<String, ?> buildShape(Map<String, String> element, String baseURL, Map<String, String> colors) {
    def key = element.key
    def issuetype = element.issuetype
    def summary = element.summary
    def shape = [:]
    shape.put('Label', "${key} / ${issuetype.toUpperCase()}: \n\n ${summary}")
    switch (issuetype) {
        case "Epic":
            shape.put('FillColor', colors.epic)
            break
        case "Bug":
            shape.put('FillColor', colors.bug)
            break
        case "Sub-task":
            shape.put('FillColor', colors.subtask)
            break
        default:
            shape.put('FillColor', colors.default)
            break
    }
    shape.put('Hyperlink', ['url': "${baseURL}/browse/${key}"])
    shape
}

ApplicationLink getPrimaryConfluenceLink() {
    def applicationLinkService = ComponentLocator.getComponent(ApplicationLinkService)
    final ApplicationLink conflLink = applicationLinkService.getPrimaryApplicationLink(ConfluenceApplicationType)
    conflLink
}

On this page