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.
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.
Navigate to your page
Edit the page
Insert a macro
Select the SmartDraw Diagram
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
}