In this documentation, I will show you how to keep the Epic link and issue link between issues and work items in Jira Cloud and Azure DevOps
Depending on your instance you can use different issueTypes.
First, we need to add a few new lines in the Outgoing script.
replica.parentId = workItem.parentId def res = httpClient.get("/_apis/wit/workitems/${workItem.key}?\$expand=relations&api-version=6.0",false) if (res.relations != null) replica.relations = res.relations |
We need the parentId to check if an issue has a parent so we can link them together.
With the httpClient we get the relations of the issues if the issue does not have a relation to another issue we don't set the "replica.relations" variable.
In the Jira Incoming sync, we'll start from the top and work to the bottom, you'll just have to Copy & paste these values and change some values so they match your instance.
It will be clear where and when you need to change these values.
The first code we are going to change is in the if(firstSync) block.
Change the projct name the your project name & Change the typeMap to your issueTypes (you can choose how you want to map your issueTypes). |
if(firstSync){ // Change <Project name> to your project name issue.projectKey = "DEMO" // This typeMap hass the values that are coming from ADO if a value is not found set it to a default value ("Task" in this case). def typeMap = [ // "ADO side":"Jira Cloud side" "User Story":"Story" ] issue.typeName = nodeHelper.getIssueType(typeMap[replica.type?.name], issue.projectKey)?.name ?: nodeHelper.getIssueType(replica.type?.name, (issue.projectKey ?: issue.project.key))?.name ?: "Task" issue.summary = replica.summary if (replica.typeName == "Epic") { issue.customFields."Epic Name".value = replica.summary } store(issue) } |
Here we will determine which issue needs to be linked to which issue, If the issue does not have a parentId it does not need to be linked.
The issueTypes shown below can be different than your issueTypes (Feature) can be something else on your instance. |
// This will check if the issueType is a Feature and if there is a parentId now it will link the epic with the feature. // And it will only link the issues as a child issue under the feature if (issue.typeName == "Feature" && replica.parentId) { def localParent = syncHelper.getLocalIssueKeyFromRemoteId(replica.parentId.toLong()) if (localParent) { issue.customFields."Epic Link".value = localParent.urn } }else { replica.relations.each { relation -> // We check on the Related attribute from ADO and link it wiht Relates in Jira if (relation.attributes.name == "Parent"){ def a = syncHelper.getLocalIssueKeyFromRemoteId(relation.url.tokenize('/')[7])//?.urn if (issue.issueLinks[0]?.otherIssueId != a?.id){ def res = httpClient.put("/rest/api/2/issue/${issue.key}", """ { "update":{ "issuelinks":[ { "add":{ "type":{ "name":"Relates" }, "outwardIssue":{ "key":"${a.urn}" } } } ] } } """) } } } } |
If you also want to map your status for multiple issueTypes you can use this function.
Change the values to the values you get from ADO (first values before the ":") and the values you have in your Jira issueTypes (last values after the ":") |
def setStatus(){ // First we determine which Issue Type has which statuses Epic, Feature, Story, etc... def statusMappingEpic = [ // "ADO values":"Jira Values" "Open":"Open", "Doing":"In Progress", "Closed":"Done" ] def statusMappingFeature = [ // "ADO values":"Jira Values" "To Do":"Open", "Doing":"In Progress", "Closed":"Done" ] def statusMappingStory = [ // "ADO values":"Jira Values" "To Do":"Open", "Doing":"In Progress", "Closed":"Done" ] def remoteStatusName = replica.status.name // Status name from the ADO side // We wil check which issueType this issue has and them map the right Statuses to it, the default value is set to the default value you want. if (issue.type.name == "Epic"){ return statusMappingEpic[remoteStatusName] ?: "Open"} if (issue.type.name == "Feature"){ return statusMappingFeature[remoteStatusName] ?: "Open"} if (issue.type.name == "User Story"){ return statusMappingStory[remoteStatusName] ?: "Open"} // We return the right value and set the right Status in your issue } // We do this after the first sync other wise it can cause troubles. if (!firstSync){ workItem.setStatus(setStatus()) } |
Now we have done the parent-child link we only need to add the System or custom fields that you also want to set in your Jira issue.
issue.summary = replica.summary issue.description = replica.description issue.comments = commentHelper.mergeComments(issue, replica) issue.attachments = attachmentHelper.mergeAttachments(issue, replica) issue.labels = replica.labels // Custom Fields //issue.customFields."CF Name".value = replica.customFields."CF Name".value |
First, we need to add a few values in the Jira Outgoing sync.
By default "replica.parentId = issue.parentId" is already in the outgoing script but check this to be sure.
// Add these to the outging script replica.linkedIssues = issue.issueLinks replica.parentId = issue.parentId |
We need the linked issues and the parentId to see in ADO which issues are linked with each other.
Here we also start from the top to the bottom, you can also copy & paste these values and change them where needed to match your instance values.
It will be clear where and when you need to change these values.
Change the projct name the your project name & Change the typeMap to your issueTypes (you can choose how you want to map your issueTypes). |
if(firstSync){ workItem.projectKey = "<project name>" def typeMap = [ // "Jira Cloud":"ADO" "Epic" : "Epic", "Feature": "Feature", "Story" : "User Story" ] workItem.typeName = nodeHelper.getIssueType(typeMap[replica.type?.name], issue.projectKey)?.name ?: nodeHelper.getIssueType(replica.type?.name, (issue.projectKey ?: issue.project.key))?.name ?: "Task" workItem.summary = replica.summary store(issue) } |
workItem.parentId = null if (replica.parentId) { def localParent = syncHelper.getLocalIssueKeyFromRemoteId(replica.parentId.toLong()) if (localParent) { workItem.parentId = localParent.id } } |
On line 1 change <ADO Project> to your actual project. |
def res =httpClient.get("/<ADO Project>/_apis/wit/workItems/${workItem.id}/revisions",true) def await = { f -> scala.concurrent.Await$.MODULE$.result(f, scala.concurrent.duration.Duration.apply(1, java.util.concurrent.TimeUnit.MINUTES)) } def creds = await(httpClient.azureClient.getCredentials()) def token = creds.accessToken() def baseUrl = creds.issueTrackerUrl() def project = workItem.projectKey def localUrl = baseUrl + '/_apis/wit/workItems/' + workItem.id int x =0 res.value.relations.each{ revision -> def createIterationBody1 = [ [ op: "test", path: "/rev", value: (int) res.value.size() ], [ op:"remove", path:"/relations/${++x}" ] ] } def linkTypeMapping = [ "relates to": "System.LinkTypes.Related" ] def linkedIssues = replica.linkedIssues if (linkedIssues) { replica.linkedIssues.each{ def localParent = syncHelper.getLocalIssueKeyFromRemoteId(it.otherIssueId.toLong()) if (!localParent?.id) { return; } localUrl = baseUrl + '/_apis/wit/workItems/' + localParent.id def createIterationBody = [ [ op: "test", path: "/rev", value: (int) res.value.size() ], [ op:"add", path:"/relations/-", value: [ rel:linkTypeMapping[it.linkName], url:localUrl, attributes: [ comment:"" ] ] ] ] def createIterationBodyStr = groovy.json.JsonOutput.toJson(createIterationBody) converter = scala.collection.JavaConverters; arrForScala = [new scala.Tuple2("Content-Type","application/json-patch+json")] scalaSeq = converter.asScalaIteratorConverter(arrForScala.iterator()).asScala().toSeq(); createIterationBodyStr = groovy.json.JsonOutput.toJson(createIterationBody) def result = await(httpClient.azureClient.ws .url(baseUrl+"/${project}/_apis/wit/workitems/${workItem.id}?api-version=6.0") .addHttpHeaders(scalaSeq) .withAuth(token, token, play.api.libs.ws.WSAuthScheme$BASIC$.MODULE$) .withBody(play.api.libs.json.Json.parse(createIterationBodyStr), play.api.libs.ws.JsonBodyWritables$.MODULE$.writeableOf_JsValue) .withMethod("PATCH") .execute()) } } |
If you also want to map your status for multiple issueTypes you can use this function.
Change the values to the values you get from Jira (first values before the ":") and the values you have in your ADO issueTypes (last values after the ":") |
You can add other Issuetype Statues.
def setStatus(){ // First we determine which Issue Type has which statuses Epic, Feature, Story, etc... def statusMappingEpic = [ // "Jira values":"ADO Values" "Open":"Open", "In Progress":"Doing", "Done":"Closed" ] def statusMappingFeature = [ // "Jira values":"ADO Values" "Open":"To Do", "In Progress":"Doing", "Done":"Closed" ] def statusMappingStory = [ // "Jira values":"ADO Values" "Open":"To Do", "In Progress":"Doing", "Done":"Closed" ] def remoteStatusName = replica.status.name // Status name from the Jira side // We wil check which issueType this issue has and them map the right Statuses to it, the default value is set to the default value you want. if (issue.type.name == "Epic"){ return statusMappingEpic[remoteStatusName] ?: "Open"} if (issue.type.name == "Feature"){ return statusMappingFeature[remoteStatusName] ?: "To Do"} if (issue.type.name == "User Story"){ return statusMappingStory[remoteStatusName] ?: "To Do"} // We return the right value and set the right Status in your issue } // We do this after the first sync other wise it can cause troubles. if (!firstSync){ workItem.setStatus(setStatus()) } |
Now we have done the parent-child link we only need to add the System or custom fields that you also want to set in your Jira issue.
workItem.summary = replica.summary workItem.description = replica.description workItem.attachments = attachmentHelper.mergeAttachments(workItem, replica) workItem.comments = commentHelper.mergeComments(workItem, replica) workItem.labels = replica.labels workItem.priority = replica.priority |
And you're done This is the implementation to keep the issue link hierarchy.
Here is an image of how a Task and a sub-task are synced over from Jira in ADO.
Here is an image of how the Epic hierarchy is synced over