Answer by Serhiy Onyshchenko on 03 June 2022
Hello there!
One way to achieve that would be to use the changeHistory available on Jira and ADO to know, which side modified which field, when to be able to compare them and choose the latest version (either accept the change on the field or reject it).
Here’s a video of how it works if configured properly:
And here’s the (full) configuration used for this video:
Jira | ADO | |
---|---|---|
Out | replica.key = issue.key replica.type = issue.type replica.assignee = issue.assignee replica.reporter = issue.reporter replica.summary = issue.summary replica.description = issue.description replica.labels = issue.labels replica.comments = issue.comments replica.resolution = issue.resolution replica.status = issue.status replica.parentId = issue.parentId replica.priority = issue.priority replica.attachments = issue.attachments replica.project = issue.project //Comment these lines out if you are interested in sending the full list of versions and components of the source project. replica.project.versions = [] replica.project.components = [] // Community 42839472: Avoid updating issue with older changes ADO <> Jira replica.changeHistory = issue.changeHistory |
replica.key = workItem.key replica.assignee = workItem.assignee replica.summary = workItem.summary replica.description = nodeHelper.stripHtml(workItem.description) replica.type = workItem.type replica.status = workItem.status replica.labels = workItem.labels replica.priority = workItem.priority replica.comments = nodeHelper.stripHtmlFromComments(workItem.comments) replica.attachments = workItem.attachments replica.project = workItem.project replica.areaPath = workItem.areaPath replica.iterationPath = workItem.iterationPath // Community 42839472: Avoid updating issue with older changes ADO <> Jira 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 ?: connection.trackerSettings.fieldValues."project" def result = await(httpClient.azureClient.ws .url(baseUrl+"/${project}/_apis/wit/workItems/${workItem.id}/updates?api-version=5.0") .withAuth(token, token, play.api.libs.ws.WSAuthScheme$BASIC$.MODULE$) .withMethod("GET") .execute()) String body = result.body() def js = new groovy.json.JsonSlurper() def dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")//"2021-10-05T12:33:10.7Z" def noMsDateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")//"2021-10-05T12:33:10.7Z" def json = js.parseText(body) workItem.changeHistory = json.value.inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory>) { _res, hJson -> if (hJson.fields?."System.RevisedDate"?.oldValue) { def author = new com.exalate.basic.domain.hubobject.v1.BasicHubUser() author.key=hJson.revisedBy.id author.active=true def _emailMatcher = hJson.revisedBy.name =~ /<([^@]+@[^>+])>/ author.email= _emailMatcher.size() > 0 ? _emailMatcher.iterator().next()[1] : null author.displayName = hJson.revisedBy.displayName author.username = hJson.revisedBy.uniqueName def date = ({ dStr -> if (dStr == null) return null try { dateFormat.parse(dStr) } catch (e1) { noMsDateFormat.parse(dStr) } })(hJson.fields?."System.RevisedDate"?.oldValue) def timestamp = date == null ? null : new java.sql.Timestamp(date.time) def changeItems = hJson.fields?.inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem>) { r, k, v -> def ci = new com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem(v.oldValue as String, v.oldValue as String, v.newValue as String, v.newValue as String, k, "system") r += ci r } ?: [] _res += new com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory(hJson.id as Long, author, timestamp, changeItems) } _res } def fieldToLastUpdateDateFn = { exalateUserKey -> { history -> history .sort { c -> c.created.time } .reverse() .findAll { c -> c.author.key != exalateUserKey } .inject([:]) { _result, c -> c.changeItems.inject(_result) { r, i -> String k = i.field if (r[k] == null) { r[k] = c.created } r } } }} replica.customKeys."fieldToLastUpdateDate" = fieldToLastUpdateDateFn("exalate")(workItem.changeHistory) |
In | if(firstSync){ issue.projectKey = "DEV" // Set type name from source issue, if not found set a default issue.typeName = nodeHelper.getIssueType(replica.type?.name, issue.projectKey)?.name ?: "Task" } issue.comments = commentHelper.mergeComments(issue, replica) issue.attachments = attachmentHelper.mergeAttachments(issue, replica) issue.labels = replica.labels // Community 42839472: Avoid updating issue with older changes ADO <> Jira def fieldToLastUpdateDateFn = { exalateUserKey -> { history -> history .sort { c -> c.created.time } .reverse() .findAll { c -> c.author.key != exalateUserKey } .inject([:]) { result, c -> c.changeItems.inject(result) { r, i -> String k = i.field if (r[k] == null) { r[k] = c.created } r } } }} if (firstSync) { issue.summary = replica.summary issue.description = replica.description } else { def HOUR = 1000 * 60 * 60 def THREE_HOURS = 3 * HOUR def remoteFieldToLastUpdateDate = replica.customKeys."fieldToLastUpdateDate" remoteFieldToLastUpdateDate = remoteFieldToLastUpdateDate.inject([:]) { r, k, v -> r[k] = new Date((v + THREE_HOURS) as Long) r } def localFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(issue.changeHistory) if (remoteFieldToLastUpdateDate."System.Description" > localFieldToLastUpdateDate."description") { issue.description = replica.description } if (remoteFieldToLastUpdateDate."System.Title" > localFieldToLastUpdateDate."summary") { issue.summary = replica.summary } if (remoteFieldToLastUpdateDate."Severity" > localFieldToLastUpdateDate."Severity") { issue.Severity = replica.Severity } } |
workItem.labels = replica.labels workItem.priority = replica.priority if(firstSync){ // Set type name from source entity, if not found set a default workItem.typeName = nodeHelper.getIssueType(replica.type?.name)?.name ?: "Task"; } workItem.attachments = attachmentHelper.mergeAttachments(workItem, replica) workItem.comments = commentHelper.mergeComments(workItem, replica) // Community 42839472: Avoid updating issue with older changes ADO <> Jira if (firstSync) { workItem.summary = replica.summary workItem.description = replica.description } else { 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 ?: connection.trackerSettings.fieldValues."project" def result = await(httpClient.azureClient.ws .url(baseUrl+"/${project}/_apis/wit/workItems/${workItem.id}/updates?api-version=5.0") .withAuth(token, token, play.api.libs.ws.WSAuthScheme$BASIC$.MODULE$) .withMethod("GET") .execute()) String body = result.body() def js = new groovy.json.JsonSlurper() def dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")//"2021-10-05T12:33:10.7Z" def noMsDateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")//"2021-10-05T12:33:10Z" def json = js.parseText(body) workItem.changeHistory = json.value.inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory>) { _res, hJson -> if (hJson.fields?."System.RevisedDate"?.oldValue) { def author = new com.exalate.basic.domain.hubobject.v1.BasicHubUser() author.key=hJson.revisedBy.id author.active=true def _emailMatcher = hJson.revisedBy.name =~ /<([^@]+@[^>+])>/ author.email= _emailMatcher.size() > 0 ? _emailMatcher.iterator().next()[1] : null author.displayName = hJson.revisedBy.displayName author.username = hJson.revisedBy.uniqueName def date = ({ dStr -> if (dStr == null) return null try { dateFormat.parse(dStr) } catch (e1) { noMsDateFormat.parse(dStr) } })(hJson.fields?."System.RevisedDate"?.oldValue) def timestamp = date == null ? null : new java.sql.Timestamp(date.time) def changeItems = hJson.fields?.inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem>) { r, k, v -> def ci = new com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem(v.oldValue as String, v.oldValue as String, v.newValue as String, v.newValue as String, k, "system") r += ci r } ?: [] _res += new com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory(hJson.id as Long, author, timestamp, changeItems) } _res } def fieldToLastUpdateDateFn = { exalateUserKey -> { history -> history .sort { c -> c.created.time } .reverse() .findAll { c -> c.author.key != exalateUserKey } .inject([:]) { _result, c -> c.changeItems.inject(_result) { r, i -> String k = i.field if (r[k] == null) { r[k] = c.created } r } } }} def HOUR = 1000 * 60 * 60 def THREE_HOURS = 3 * HOUR def remoteFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(replica.changeHistory) def localFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(workItem.changeHistory) localFieldToLastUpdateDate = localFieldToLastUpdateDate.inject([:]) { r, k, v -> r[k] = new Date(v.time + THREE_HOURS) r } if (remoteFieldToLastUpdateDate."description" > localFieldToLastUpdateDate."System.Description") { workItem.description = replica.description } if (remoteFieldToLastUpdateDate."summary" > localFieldToLastUpdateDate."System.Title") { issue.summary = replica.summary } } |
Let’s dive into the details to figure out how to adapt this and add other fields to this conflict handling script.
Sending change history
If we look closer to the outgoing scripts on both ends:
Jira | ADO | |
---|---|---|
Out | // Community 42839472: Avoid updating issue with older changes ADO <> Jira replica.changeHistory = issue.changeHistory |
... // Community 42839472: Avoid updating issue with older changes ADO <> Jira ... def result = await(... .url(baseUrl+"/${project}/_apis/wit/workItems/${workItem.id}/updates?api-version=5.0") ... .withMethod("GET") ...) ... workItem.changeHistory = ... def fieldToLastUpdateDateFn = { exalateUserKey -> { history -> ... }} replica.customKeys."fieldToLastUpdateDate" = fieldToLastUpdateDateFn("exalate")(workItem.changeHistory) |
we calculate the issue.changeHistory and workItem.changeHistory
On Jira it comes pretty much out of the box
On ADO it’s a bit more complicated:
- we make a REST API request to ADO:
GET /${project}/_apis/wit/workItems/${workItem.id}/updates?api-version=5.0 - results of which we convert into workItem.changeHistory
- and then we use the fieldToLastUpdateDateFn to convert the change history into a simplified version of the change history
- send simplified version of change history from ADO to Jira in a custom key called replica.customKeys.“fieldToLastUpdateDate”
Using the remote change history when receiving changes
So the most important part of the entire configuration here is that instead of doing
Jira | ADO | |
---|---|---|
In | ... issue.summary = replica.summary issue.description = replica.description ... |
... workItem.summary = replica.summary workItem.description = replica.description ... |
we do a more sophisticated check for change histories
Jira | ADO | |
---|---|---|
In | // Community 42839472: Avoid updating issue with older changes ADO <> Jira ... if (firstSync) { issue.summary = replica.summary issue.description = replica.description } else { ... if (remoteFieldToLastUpdateDate."System.Description" > localFieldToLastUpdateDate."description") { issue.description = replica.description } if (remoteFieldToLastUpdateDate."System.Title" > localFieldToLastUpdateDate."summary") { issue.summary = replica.summary } } |
// Community 42839472: Avoid updating issue with older changes ADO <> Jira if (firstSync) { workItem.summary = replica.summary workItem.description = replica.description } else { ... if (remoteFieldToLastUpdateDate."description" > localFieldToLastUpdateDate."System.Description") { workItem.description = replica.description } if (remoteFieldToLastUpdateDate."summary" > localFieldToLastUpdateDate."System.Title") { issue.summary = replica.summary } } |
Note, how we read the names of fields from the history records of ADO “System.Description” for description and “System.Title” for summary.
please, also note that if you wanted to add conflict handling for other fields, like status, you’d need to make the following replacements:
Jira | ADO | |
---|---|---|
In | ...def statusMapping = [ "To Do":"Open", "Doing" : "In Progress", "Done" : "Closed" ]issue.setStatus(statusMapping[replica.status.name]) |
... def statusMapping = [ "Open":"To Do", "In Progress" : "Doing", "Closed" : "Done" ] workItem.setStatus(statusMapping[replica.status.name]) |
would be replaced with
Jira | ADO | |
---|---|---|
In | ...def statusMapping = [ "To Do":"Open", "Doing" : "In Progress", "Done" : "Closed" ]if (firstSync) { ... issue.setStatus(statusMapping[replica.status.name]) } else { ... if (remoteFieldToLastUpdateDate."System.State" > localFieldToLastUpdateDate."status") { issue.setStatus(statusMapping[replica.status.name]) } } |
... def statusMapping = [ "Open":"To Do", "In Progress" : "Doing", "Closed" : "Done" ] if (firstSync) { ... issue.setStatus(statusMapping[replica.status.name]) } else { ... if (remoteFieldToLastUpdateDate."status" > localFieldToLastUpdateDate."System.State") { workItem.setStatus(statusMapping[replica.status.name]) } } |
A note about time
In my environments, there’s a 3h difference between how Jira Cloud and ADO report changeHistory dates:
in Jira Cloud all the dates seem to be matching my Jira’s time zone (EEST), while ADO reports everything in UTC.
So if I make changes on Jira at 12 AM GMT and in ADO at 12:00:02 AM GMT (2 seconds apart from one-another ) Jira would report 3:00:00 and ADO 0:00:02
To accomodate for that, I made it that the incoming scripts add 3 hours to ADO:
Jira | ADO | |
---|---|---|
In | // Community 42839472: Avoid updating issue with older changes ADO <> Jira ... if (firstSync) { ... } else { def HOUR = 1000 * 60 * 60 def THREE_HOURS = 3 * HOUR def remoteFieldToLastUpdateDate = replica.customKeys."fieldToLastUpdateDate" remoteFieldToLastUpdateDate = remoteFieldToLastUpdateDate.inject([:]) { r, k, v -> r[k] = new Date((v + THREE_HOURS) as Long) r } ... } |
// Community 42839472: Avoid updating issue with older changes ADO <> Jira if (firstSync) { ... } else { ... def HOUR = 1000 * 60 * 60 def THREE_HOURS = 3 * HOUR ... def localFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(workItem.changeHistory) localFieldToLastUpdateDate = localFieldToLastUpdateDate.inject([:]) { r, k, v -> r[k] = new Date(v.time + THREE_HOURS) r } ... } |
Let me know if you don’t find a way to handle conflicts for some fields.
Happy Exalating!