Visualizing Jira Issue Dependencies

Overview

If you could see how issues are related and dependent on each other at a glance, you could more easily pinpoint a potential choke point or bottleneck.

I created this example report for VisualScript that could grab data from Jira and show you how issues are related to a "target" issue. While the hierarchical relationships of project, issues and subtask sare used to describe the primary form of organization among issues, peer relationships provide valuable insight into how the issues are related, interconnected and dependent upon each other. Jira implements these relationships as Issue Links. The most commonly used link is "blocks", which includes tasks that are "inward" to our subject task and are therefore blocking our task from beginning. There are also "outward" links to other issues which are blocked from beginning until our task completes. In addition to "blocks", Jira defines Issue Links for "relates to", "duplicates" and "clones". Administrators can also define custom Issue Links to meet an organization's specific needs.

While this information is available in the Jira interface in a list form, it is often very useful to be able to see the relationships in a more visual manner. To provide access from the script output to the actual issues themselves, I added a hyperlink on each issue which takes you to the actual issue page in Jira. You could easily modify this script to use different colors and link definitions that suits your organization's needs.

Jira issue dependency

The user interface I created for any potential end users allows them to specify a central (or "target") issue, and a link name (or "relationship").

Target issue

How I Implemented It

Obtaining the Data

The first step to creating any VisualScript Studio report is getting your data, in this case from Jira. Using the parameters passed in from the UI, I use VSCallFunctionForJiraQuery to query Jira using the Jira REST API.

function VisualScript(localhost, params, callback) { var ID = 1; paramsObj = JSON.parse(params); VSCallFunctionForJiraIssue(gSDJSLocalHost, paramsObj.key + "?expand=names", handleIssue);

The params passed in to the VisualScript function from the user interface described above are represented as a JSON string. The first thing I do is parse the params back into actual JavaScript objects using JSON.parse(). I then call VSCallFunctionForJiraQuery with the "key" from the user interface to get information for our central/target issue. The parameter "expand=names" is added to the parameters so that Jira returns extra information about its use of fields. I need this because the "epic" of an issue is not in the usual set of fields an issue maintains, but is instead a custom field that can have a different name for each Jira installation. We use the information returned by the "expand=names" to discover the name of that custom field. The handleIssue function is called with the results.

Building the Model

The next thing I do is to build a model of the information that I want to depict. I build a list of outbound issues for "blocking" Issue Links. These are the issues that are blocked by the target issue. I also build a list of inbound issues (issues that block the target issue). As the last step of building the model, I obtain information about the target issue's epic (if any).

Here's the code used to build the model for the target issue as well as the "inward" and "outward" issues:

// Build the model // Model for "target issue" model.targetIssue = { key: issue.key, summary: issue.fields.summary, ID: ID++ }; // Build the model for the issue links that match our parameter linkName if (paramsObj && paramsObj.linkName) { for (i = 0; i < issue.fields.issuelinks.length; i++) { if (issue.fields.issuelinks[i].type.name.toLowerCase() == paramsObj.linkName.toLowerCase()) { issueLink = issue.fields.issuelinks[i]; linkIssueDetail = issueLink.inwardIssue ? issueLink.inwardIssue : issueLink.outwardIssue; var issueInfo = { key: linkIssueDetail.key, summary: linkIssueDetail.fields.summary, ID: ID++ }; if (issueLink.inwardIssue) { model.inwardName = issueLink.type.inward; model.inwardIssues.push(issueInfo); } else { model.outwardName = issueLink.type.outward; model.outwardIssues.push(issueInfo); } } } }

This next part is a bit tricky. There may or may not be an epic associated with our central task. If there is, I'll have to find the custom field name that's used for the "epic link" (key of the issue that is the epic), and then I'll need to issue another query to get information about the epic. Alternatively, if there is no epic for our central issue, I can skip this step and proceed with building the VisualScript from the model.

// Now find the epic name (if any). If there is one, it's a custom field; we need to find which one it is by examining the Names object if (issue.names) { for (var key in issue.names) { if (issue.names[key] == "Epic Link") { epicName = issue.fields[key]; // Found the custom field name for the epic.... does our issue have that? break; } } if (epicName) { // Do BuildVisualScript asynchronously, when epicHandler completes deferBuildVisualScript = true; // Get information for the epic. epicHandler called with results VSCallFunctionForJiraIssue(gSDJSLocalHost, epicName, epicHandler, params); function epicHandler(epicIssue, params) { if (epicIssue) { model.epic = { key: epicIssue.key, summary: epicIssue.fields.summary, ID: ID++ }; } buildVisualScript(); } } }

Remember the extra information I obtained on the query by adding the "?expand=names" parameter? Here's where I use it. I scan the target issue's names property looking for the one with value "Epic Link". If found, I see if the central issue has a custom field with that key value. If it has one (and it's not undefined or null), then that's the "epicName". If there's an epicName, I issue a VisualScript SDK call call to get information for it. All of this is done asynchronously in the epicHandler function. I add the epic information to the model, then call buildVisualScript. If there was no epicName found, I avoid issuing the 2nd query and directly call buildVisualScript.

Constructing the VisualScript from the Model

Once the model is built, the next step is to use the model to construct VisualScript objects using the SDK. After setting up the document, I add a table with the appropriate column and row headers. I then iterate through the model's outbound, then inbound arrays adding VisualScript shapes for each. I use the bottom-most element of the inbound array to draw a "return" arrow (indicating direction), and the topmost element of the outbound array. Finally I add a shape for the epic (if there is one), and a return arrow for it. After some calls to setup the document and the table (see full script for details), I issue the calls to set up a shape for the target issue, a shape container of the inward issues (in the second table) and a shape container of outward issues. Each container has a "return line" added that I use to emphasize a relationship.

// Add shape for the "target" issue cell = rootTable.AddCell(3, 3); shape = cell.AddShape() .SetLabel(model.targetIssue.key + "\n" + model.targetIssue.summary) .SetID(model.targetIssue.ID) .SetFillColor("#b3d7ee") .SetTextColor("#333333") .SetTextTruncate(60) .SetLineThickness(0) .SetHyperlink(localhost + "/browse/" + model.targetIssue.key); // Build shapes for inward issues if (model.inwardIssues.length) { buildLinkedIssueCell(model.inwardName, model.inwardIssues, 2); // Return line.... from target issue to last/bottommost of the inward issues vs.AddReturn(model.targetIssue.ID, model.inwardIssues[model.inwardIssues.length - 1].ID) .SetStartDirection(VS.Directions.Top) .SetEndDirection(VS.Directions.Right) .Curved(true) .SetLineThickness(2) .SetLineColor("#0070C0") .SetLabel("inward"); } else { rootTable.AddCell(2,1) .SetFillColor("#EEEEEE"); } // Build shapes for outward issues if (model.outwardIssues.length) { buildLinkedIssueCell(model.outwardName, model.outwardIssues, 4); // Return line.... from target issue to topmost of the outward issues vs.AddReturn(model.outwardIssues[0].ID, model.targetIssue.ID) .SetStartDirection(VS.Directions.Right) .SetEndDirection(VS.Directions.Bottom) .Curved(true) .SetLineThickness(2) .SetLineColor("#0070C0") .SetLabel("outward"); } else { rootTable.AddCell(4,1) .SetFillColor("#EEEEEE"); }

I use the following code to build a shape container for the inward and outward "sides". I call the function twice (once for inward and one for outward), passing it the name of the link, the issue list, and the target row in the table to place the shape container. I then build the shape container in the target table row and add a shape for each issue in the list.

// Build a cell containing all the issues of a "side" of an issueLink (inward or outward) function buildLinkedIssueCell(linkName, issueList, rowTarg) { // Row header cell = rootTable.AddCell(rowTarg, 1) .SetLabel(model.inwardName); // Create the cell to hold the shape container that will hold all the shapes for the inward issues cell = rootTable.AddCell(rowTarg, 3); var cellShape = cell.AddShape() .Hide(true); var shapeContainer = cellShape.AddShapeContainer(); for (i = 0; i < issueList.length; i++) { // Add a shape to the shapecontainer for this row (cell->shape->shapecontainer) shapeContainer.AddShape() .SetID(issueList[i].ID) .SetLabel(issueList[i].key + "\n" + issueList[i].summary) .SetShapeType(VS.ShapeTypes.RoundedRectangle) .SetFillColor("#b3d7ee") .SetTextColor("#333333") .SetTextTruncate(60) .SetHyperlink(localhost + "/browse/" + issueList[i].key); } }

As the final part of building the VisualScript from the model, I add a shape and a return line for the epic (if there was one).

// If there's an epic, build a shape (and return line) for the epic if (model.epic) { var epicCell = rootTable.AddCell(3, 2); // Add a shape to the cell var epicCellShape = epicCell.AddShape(); epicCellShape.Hide(true); // Add a ShapeContainer to the cell's shape var epicShapeContainer = epicCellShape.AddShapeContainer() .SetArrangement(VS.ShapeContainerArrangement.Matrix) .SetAlignH(VS.TextAlignH.Center) .SetAlignV(VS.TextAlignV.Center); // Add the epic shape to the cell's shape container epicShapeContainer.AddShape() .SetLabel(model.epic.key + "\n" + model.epic.summary) .SetID(model.epic.ID) .SetShapeType(VS.ShapeTypes.RoundedRectangle) .SetFillColor("#FBD585") .SetTextColor("#333333") .SetTextTruncate(60) .SetHyperlink(localhost + "/browse/" + model.epic.key); // Add a return (line with arrow) to point from the epic to the issue shape vs.AddReturn(model.epic.ID, model.targetIssue.ID) .SetStartDirection(VS.Directions.Right) .SetEndDirection(VS.Directions.Left) // .Curved(true) .SetLineThickness(2) .SetLineColor("#2684FF"); }

Rendering the VisualScript

The last step is to issue the callback function with the VisualScript object (vs). This takes care of producing an image and displaying it in the gadget.

// Render the VisualScript
callback(vs.toJSON());

Creating the UI

After the script is complete, I set up the UI so that the user could provide the required parameters. In the top section of the VisualScript Studio edit page, you'll see a field labeled "Parameter Template". The specification string for the UI Builder's "Parameter Template" is:

{"key":"{{formValue1}}", "linkName":"{{formValue2}}"}

This sets up 2 parameters: "key" and "linkName". These will be the key names in the JavaScript object that will be built for this parameter block. The values for the key/value pairs will be whatever the user types into the user interface.

When I enter the above string is entered into the UI Builder's Parameter Template, this form is displayed:

UI form

I can then override the "Displayed Name" and provide a default for the 2nd parameter (linkName). I'll default it to "blocks", since that seems to be the most popular dependency, and will have the added benefit of illustrating the type of information that's expected there.

UI form project name

More Information

This is just one example of the useful visuals that can be produced using VisualScript and VisualScript Studio with a minimum of development effort.

The full source for this script can be seen here. To make the code easier to understand, I highlighted the part used for querying the data in green, the part for building the model in orange, and the part that creates VisualScript from the data in blue.

function VisualScript(localhost, params, callback) { var ID = 1; // Shows outbound and inbound relationships of a single issue. Please refer to https://www.visualscript.com/learn/visualscript-sdk/ for information on use of VisualScript paramsObj = JSON.parse(params); VSCallFunctionForJiraIssue(gSDJSLocalHost, paramsObj.key + "?expand=names", handleIssue); // Handle response from query. Show relationships of the parameter issue. function handleIssue(issue) { var vs = {}, i, epicName, linkIssueDetail, issueLink, deferBuildVisualScript = false; var model = { targetIssue: {}, inwardName: "", inwardIssues: [], outwardName: "", outwardIssues: [], epic: null }; if (!issue) { alert("No issues found for parameters. Please provide parameters in the form {\"key\" : \"IssueKeyHere\", \"linkName\": \"yourLinknameHere\"}. \n\n For example {\"key\" : \"SCRUM1-14\", \"linkName\": \"blocks\"}"); callback(null); return; }
// Build the model // Model for "target issue" model.targetIssue = { key: issue.key, summary: issue.fields.summary, ID: ID++ }; // Build the model for the issue links that match our parameter linkName if (paramsObj && paramsObj.linkName) { for (i = 0; i < issue.fields.issuelinks.length; i++) { if (issue.fields.issuelinks[i].type.name.toLowerCase() == paramsObj.linkName.toLowerCase()) { issueLink = issue.fields.issuelinks[i]; linkIssueDetail = issueLink.inwardIssue ? issueLink.inwardIssue : issueLink.outwardIssue; var issueInfo = { key: linkIssueDetail.key, summary: linkIssueDetail.fields.summary, ID: ID++ }; if (issueLink.inwardIssue) { model.inwardName = issueLink.type.inward; model.inwardIssues.push(issueInfo); } else { model.outwardName = issueLink.type.outward; model.outwardIssues.push(issueInfo); } } } } // Now find the epic name (if any). If there is one, it's a custom field; we need to find which one it is by examining the Names object if (issue.names) { for (var key in issue.names) { if (issue.names[key] == "Epic Link") { epicName = issue.fields[key]; // Found the custom field name for the epic.... does our issue have that? break; } } if (epicName) { // Do BuildVisualScript asynchronously, when epicHandler completes deferBuildVisualScript = true; // Get information for the epic. epicHandler called with results VSCallFunctionForJiraIssue(gSDJSLocalHost, epicName, epicHandler, params); function epicHandler(epicIssue, params) { if (epicIssue) { model.epic = { key: epicIssue.key, summary: epicIssue.fields.summary, ID: ID++ }; } buildVisualScript(); } } } // If not deferring the render of VisualScript, render it now. if (!deferBuildVisualScript) { buildVisualScript(); }
// Build VisualScript from model function buildVisualScript() { var vs = {}, i, rootShape, rootTable, cell, ret; vs = new VS.Document(); rootShape = vs.GetTheShape() .SetFillColor("#FFFFFF"); rootTable = rootShape.AddTable(4, 3) .SetRowHeight(120) .SetColumnWidth(150); rootTable.AddRowProperties(1) .SetHeight(40); rootTable.AddRowProperties(2) .SetLineColor("#D5D5D5"); rootTable.AddRowProperties(3) .SetLineColor("#D5D5D5"); rootTable.AddColumnProperties(1) .SetLineColor("#D5D5D5"); rootTable.AddColumnProperties(2) .SetLineColor("#D5D5D5"); // Column headers rootTable.AddCell(1, 1) .SetLabel("ISSUE DEPENDENCY") .SetFillColor("#435865") .SetTextColor("#EEEEEE") .SetTextAlignV(VS.TextAlignV.Bottom) .SetTextSize(8); rootTable.AddCell(1, 2) .SetLabel("EPIC") .SetFillColor("#435865") .SetTextColor("#EEEEEE") .SetTextAlignV(VS.TextAlignV.Bottom) .SetTextSize(8); rootTable.AddCell(1, 3) .SetLabel("ISSUES") .SetFillColor("#435865") .SetTextColor("#EEEEEE") .SetTextAlignV(VS.TextAlignV.Bottom) .SetTextSize(8); // Row header rootTable.AddCell(3, 1) .SetLabel("EPIC AND CENTRAL ISSUE") .SetFillColor("#EEEEEE") .SetTextColor("#333333") .SetTextAlignH(VS.TextAlignH.Right) .SetTextSize(8); // Add shape for the "target" issue cell = rootTable.AddCell(3, 3); shape = cell.AddShape() .SetLabel(model.targetIssue.key + "\n" + model.targetIssue.summary) .SetID(model.targetIssue.ID) .SetFillColor("#b3d7ee") .SetTextColor("#333333") .SetTextTruncate(60) .SetLineThickness(0) .SetHyperlink(localhost + "/browse/" + model.targetIssue.key); // Build shapes for inward issues if (model.inwardIssues.length) { buildLinkedIssueCell(model.inwardName, model.inwardIssues, 2); // Return line.... from target issue to last/bottommost of the inward issues vs.AddReturn(model.targetIssue.ID, model.inwardIssues[model.inwardIssues.length - 1].ID) .SetStartDirection(VS.Directions.Top) .SetEndDirection(VS.Directions.Right) .Curved(true) .SetLineThickness(2) .SetLineColor("#0070C0") .SetLabel("inward"); } else { rootTable.AddCell(2,1) .SetFillColor("#EEEEEE"); } // Build shapes for outward issues if (model.outwardIssues.length) { buildLinkedIssueCell(model.outwardName, model.outwardIssues, 4); // Return line.... from target issue to topmost of the outward issues vs.AddReturn(model.outwardIssues[0].ID, model.targetIssue.ID) .SetStartDirection(VS.Directions.Right) .SetEndDirection(VS.Directions.Bottom) .Curved(true) .SetLineThickness(2) .SetLineColor("#0070C0") .SetLabel("outward"); } else { rootTable.AddCell(4,1) .SetFillColor("#EEEEEE"); } // If there's an epic, build a shape (and return line) for the epic if (model.epic) { var epicCell = rootTable.AddCell(3, 2); // Add a shape to the cell var epicCellShape = epicCell.AddShape(); epicCellShape.Hide(true); // Add a ShapeContainer to the cell's shape var epicShapeContainer = epicCellShape.AddShapeContainer() .SetArrangement(VS.ShapeContainerArrangement.Matrix) .SetAlignH(VS.TextAlignH.Center) .SetAlignV(VS.TextAlignV.Center); // Add the epic shape to the cell's shape container epicShapeContainer.AddShape() .SetLabel(model.epic.key + "\n" + model.epic.summary) .SetID(model.epic.ID) //.SetShapeType(VS.ShapeTypes.RoundedRectangle) .SetFillColor("#FBD585") .SetTextColor("#333333") .SetTextTruncate(60) .SetLineThickness(0) .SetHyperlink(localhost + "/browse/" + model.epic.key); // Add a return (line with arrow) to point from the epic to the issue shape vs.AddReturn(model.epic.ID, model.targetIssue.ID) .SetStartDirection(VS.Directions.Right) .SetEndDirection(VS.Directions.Left) // .Curved(true) .SetLineThickness(2) .SetLineColor("#2684FF"); } // Render the VisualScript callback(vs.toJSON()); // Build a cell containing all the issues of a "side" of an issueLink (inward or outward) function buildLinkedIssueCell(linkName, issueList, rowTarg) { // Row header cell = rootTable.AddCell(rowTarg, 1) .SetLabel(linkName.toUpperCase()) .SetFillColor("#EEEEEE") .SetTextSize(8) .SetTextAlignH(VS.TextAlignH.Right) .SetTextColor("#333333"); // Create the cell to hold the shape container that will hold all the shapes for the inward issues cell = rootTable.AddCell(rowTarg, 3); var cellShape = cell.AddShape() .Hide(true); var shapeContainer = cellShape.AddShapeContainer() .SetVerticalSpacing(10); for (i = 0; i < issueList.length; i++) { // Add a shape to the shapecontainer for this row (cell->shape->shapecontainer) shapeContainer.AddShape() .SetID(issueList[i].ID) .SetLabel(issueList[i].key + "\n" + issueList[i].summary) .SetFillColor("#b3d7ee") .SetTextColor("#333333") .SetLineThickness(0) .SetTextTruncate(60) .SetHyperlink(localhost + "/browse/" + issueList[i].key); } } } } }