This use case shows how to synchronize issue links and relationships bidirectionally between Jira Cloud and Azure DevOps.
The scripts retrieve the relationships from each platform, identify the corresponding synchronized issue or work item, and recreate the equivalent relationship on the other side.
In this example:
- Jira Relates links are mapped to Azure DevOps Related relations.
- Jira Blocks / Is blocked by links are mapped to Azure DevOps dependency relations.
- Azure DevOps Duplicate, Related, and Predecessor relations are mapped to their Jira equivalents.
- Parent/child relationships are synchronized separately using
parentId.
The mappings can be extended depending on the Jira link types and Azure DevOps relation types used in your environment.
Script Overview
The synchronization is handled differently in each direction.
Azure DevOps → Jira Cloud
On the Azure DevOps outgoing side, the work item’s relations are retrieved through the Azure DevOps REST API using $expand=relations.
These relations are added to the replica:
replica.relations = res.relations
On the Jira Cloud incoming side, the script reads those relations, identifies the corresponding Jira issue using syncHelper.getLocalIssueKeyFromRemoteId(), checks whether the Jira issue is already linked, and creates the appropriate Jira issue link through the Jira REST API.
The example maps:
| Azure DevOps | Jira Cloud |
|---|---|
| Duplicate | Duplicate |
| Related | Relates |
| Predecessor | Blocks |
Jira Cloud → Azure DevOps
Jira issue links are added directly to the replica:
replica.issueLinks = issue.issueLinks
On the Azure DevOps incoming side, the Jira link type is translated into the corresponding Azure DevOps relation type.
The example maps:
| Jira Cloud | Azure DevOps |
|---|---|
| Relates / Relates to | Related |
| Blocks | Dependency-Forward |
| Is blocked by | Dependency-Reverse |
Because Azure DevOps work item relations are updated using a JSON Patch request, the script uses the lower-level Azure DevOps HTTP client and retrieves the PAT already configured in Exalate. No credentials need to be hardcoded in the synchronization rules.
Final Solution
Azure DevOps Outgoing Sync
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
}
Azure DevOps Incoming Sync
import groovy.json.JsonOutput
if (replica.parentId) {
def localParent =
syncHelper.getLocalIssueKeyFromRemoteId(
replica.parentId as Long
)
if (localParent?.id) {
workItem.parentId = localParent.id
}
}
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.getPATCredentials()
)
def token = creds.patAccessToken()
def baseUrl = creds.issueTrackerUrl()
def project = workItem.projectKey
def linkTypeMapping = [
"relates to" : "System.LinkTypes.Related",
"relates" : "System.LinkTypes.Related",
"blocks" : "System.LinkTypes.Dependency-Forward",
"is blocked by" : "System.LinkTypes.Dependency-Reverse"
]
// Retrieve the current work item including its relations
def current = httpClient.get(
"/${project}/_apis/wit/workitems/${workItem.id}?\$expand=relations&api-version=6.0",
true
)
def currentRelations = current?.relations ?: []
def patchBody = []
// Remove only relations previously created from Jira.
// Relations created locally in Azure DevOps are preserved.
for (int i = currentRelations.size() - 1; i >= 0; i--) {
def rel = currentRelations[i]
if (rel.attributes?.comment == "Synced from Jira issue link") {
patchBody << [
op : "remove",
path : "/relations/${i}"
]
}
}
// Add the current Jira links
replica.issueLinks?.each { jiraLink ->
def adoRelType =
linkTypeMapping[
jiraLink.linkName?.toLowerCase()
]
if (!adoRelType) return
def localLinkedItem =
syncHelper.getLocalIssueKeyFromRemoteId(
jiraLink.otherIssueId as Long
)
if (!localLinkedItem?.id) return
def linkedUrl =
"vstfs:///WorkItemTracking/WorkItem/${localLinkedItem.id}"
patchBody << [
op : "add",
path : "/relations/-",
value: [
rel : adoRelType,
url : linkedUrl,
attributes: [
comment: "Synced from Jira issue link"
]
]
]
}
// Apply the relation changes
if (patchBody) {
def patchJson =
JsonOutput.toJson(patchBody)
def basicAuth = ":" + token
def scalaHeaders =
scala.collection.JavaConverters
.asScalaIteratorConverter([
new scala.Tuple2(
"Content-Type",
"application/json-patch+json"
),
new scala.Tuple2(
"Authorization",
"Basic " +
basicAuth.bytes.encodeBase64().toString()
)
].iterator())
.asScala()
.toSeq()
def result = await(
httpClient.azureClient.ws
.url(
"${baseUrl}/${project}/_apis/wit/workitems/${workItem.id}?api-version=6.0"
)
.addHttpHeaders(scalaHeaders)
.withBody(
play.api.libs.json.Json.parse(patchJson),
play.api.libs.ws.JsonBodyWritables$.MODULE$
.writeableOf_JsValue
)
.withMethod("PATCH")
.execute()
)
}
Jira Cloud Outgoing Sync
replica.parentId = issue.parentId
replica.issueLinks = issue.issueLinks
Jira Cloud Incoming Sync
// Check whether the linked issue already exists
// in any of the current Jira issue links.
def linkExists = { linkedIssue ->
issue.issueLinks?.any { existingLink ->
existingLink.otherIssueId?.toString() ==
linkedIssue.id?.toString()
} ?: false
}
replica.relations?.each { relation ->
// The ADO work item ID is the last value in the relation URL.
def remoteId =
relation.url?.tokenize('/')?.last()
if (!remoteId) return
// Find the corresponding synchronized Jira issue.
def linkedIssue =
syncHelper.getLocalIssueKeyFromRemoteId(
remoteId
)
if (!linkedIssue) return
// Avoid creating the link again if it already exists.
if (linkExists(linkedIssue)) return
def jiraLinkType = null
if (relation.attributes?.name == "Duplicate") {
jiraLinkType = "Duplicate"
} else if (
relation.attributes?.name == "Related"
) {
jiraLinkType = "Relates"
} else if (
relation.attributes?.name == "Predecessor"
) {
jiraLinkType = "Blocks"
}
// Ignore relation types that are not mapped.
if (!jiraLinkType) return
httpClient.put(
"/rest/api/2/issue/${issue.key}",
"""
{
"update": {
"issuelinks": [
{
"add": {
"type": {
"name": "${jiraLinkType}"
},
"outwardIssue": {
"key": "${linkedIssue.urn}"
}
}
}
]
}
}
"""
)
}
Notes
- The linked Jira issue or Azure DevOps work item must already be synchronized for:
syncHelper.getLocalIssueKeyFromRemoteId()
-
Additional relationship types can be added to the scripts as required.
-
The Azure DevOps incoming script retrieves the PAT and Azure DevOps URL from the credentials already configured in Exalate. No credentials or PATs need to be hardcoded in the synchronization rules.
-
The Jira incoming script checks all existing issue links before creating a new one, preventing the same linked issue from being added repeatedly.
Version
5.37.0