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 }