Answer by Serhiy Onyshchenko on 16 May 2023
Hey, Guy Toyber ,
Thanks for reaching out to the community for this one.
There’s a way to configure Exalate to track the reasoning behind updates in a text custom field.
For this example, I’m using Jira <> Jira synchronization.
Here’s a video showing the solution:
Basically, I needed to configure first the conflict handling (from this community article: Avoid updating issue with older changes ADO <> Jira, Jira <> Jira, ServiceNow <> Jira (old community)),
and then modify it quite a bit (Chapter 2) to have more useful info in the reasoning log.
Chapter 1: main logic
On the source side
I needed to add
replica.changeHistory = issue.changeHistory
replica.created = issue.created
to the outgoing sync script
On destination side
I need to create some sort of a place where I’d aggregate all the reasons for changes:
def changeLogBuilder = new StringBuilder();
I need to describe the reasons, why the source triggered synchronization:
def latestChange = replica
.changeHistory
.sort { c -> c.created.time }
.reverse()
.find()
def latestChangeMsg = (latestChange == null) ?
"${replica.key} created by ${userStr(replica.reporter)} on ${replica.created}" :
"${latestChange.changeItems.field.join(", ")} updated by ${userStr(latestChange.author)} on ${latestChange.created}"
def syncMsg = firstSync ?
"First sync" :
"Sync"
changeLogBuilder
.append("# $syncMsg triggered due to $latestChangeMsg")
On the incoming, for every line like
issue.AAA = ...
I needed to add some reasoning to the changeLogBuilder:
changeLogBuilder
.append("\n# ")
.append(
"Overwriting AAA because Exalate is configured to do it on every sync"
)
if the line is surrounded by an if:
if (XXX && YYY) {
issue.ZZZ = ...
}
Then both XXX and YYY need to be mentioned in the reasoning message:
changeLogBuilder
.append("\n# ")
.append("Setting ZZZ to ... because XXX and YYY")
if the right hand side of the assignment uses a ternary statement:
issue.AAA = BBB ? CCC : DDD
then the condition BBB has to be mentioned in the message:
def msg = BBB ?
"BBB is true and so AAA is set to ${CCC}" :
"BBB is false and so AAA is set to ${DDD}"
note, that elvis operator:
issue.XXX = YYY ?: ZZZ
is just a shorter ternary (same thing can be said this way):
issue.XXX = (YYY == null) ? YYY : ZZZ
For comments and attachments, since we’re using these special functions mergeComments and mergeAttachments, the reasoning also takes a special form:
issue.comments = commentHelper.mergeComments(issue, replica)
issue.attachments = attachmentHelper.mergeAttachments(issue, replica)
replaced with
issue.comments = commentHelper.mergeComments(issue, replica)
if (issue.comments != issueBeforeScript.comments) {
if (!replica.removedComments.empty) {
changeLogBuilder
.append("\n# ")
.append(
"There were ${replica.removedComments.size()} comments removed on the remote side, so Exalate is going to remove them here too".toString()
)
}
if (!replica.addedComments.empty) {
changeLogBuilder
.append("\n# ")
.append(
"There were ${replica.addedComments.size()} comments added on the remote side by ${replica.addedComments.collect { userStr(it?.author) }.join(", ")}, so Exalate is going to add them here too".toString()
)
}
}
issue.attachments = attachmentHelper.mergeAttachments(issue, replica)
if (issue.attachments != issueBeforeScript.attachments) {
if (!replica.removedAttachments.empty) {
changeLogBuilder
.append("\n# ")
.append(
"There were ${replica.removedComments.size()} attachments removed on the remote side, so Exalate is going to remove them here too".toString()
)
}
if (!replica.addedAttachments.empty) {
changeLogBuilder
.append("\n# ")
.append(
"There were ${replica.addedAttachments.size()} attachments added on the remote side by ${replica.addedAttachments.collect { userStr(it?.author) }.join(", ")}, so Exalate is going to add them here too".toString()
)
}
}
if you’re using conflict handling script (Avoid updating issue with older changes ADO <> Jira, Jira <> Jira, ServiceNow <> Jira (old community)) for some fields, than your code looks like this:
if (firstSync) {
...
issue."BBB" = replica."AAA"
...
} else {
...
if(remoteFieldToLastUpdateDate."AAA" > localFieldToLastUpdateDate."BBB") {
issue."BBB" = replica."AAA"
}
...
}
I’d modified the conflict handling helper functions a bit (discussed in Chapter 2), so now these blocks would look like this instead:
if (firstSync || remoteFn()?."AAA"?.date > localFn()?."BBB"?.date) {
issue."BBB" = replica."AAA"
def reason = firstSync ?
"it's first sync, and there is no local value yet" :
"it was changed by ${userStr(remoteFn()?."AAA"?.who)} "+
"more recently (${remoteFn()?."AAA"?.date}) on remote side "+
"than on local side (${localFn()?."BBB"?.date})"
changeLogBuilder
.append("\n# ")
.append(
"Overwriting \"BBB\" because $reason".toString()
)
} else {
changeLogBuilder
.append("\n# ")
.append(
"Not touching \"BBB\" because "+
"it is not the first sync and "+
"it was changed by ${userStr(localFn()?."BBB"?.who)} "+
"more recently (${localFn()?."BBB"?.date}) locally "+
"than on the remote side (${remoteFn()?."AAA"?.date})".toString()
)
}
Finally, we’re making sure the change log we’d been accumulating this whole time is put into the “Exalate Change Reason” custom field:
issue."Exalate Change Reason" = changeLogBuilder.toString()
Chapter 2: helper methods and conflict handling update
So I had to change
// 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
}
}
}}
into
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] = ["date":c.created,"who":c.author]
}
r
}
}
}}
def changeDateFnFn = { exalateUser, timeDiffHours, changeHistory ->
def cache = null
def fn = { ->
if(cache == null) {
def HOUR = 1000 * 60 * 60
def TIME_DIFF = timeDiffHours * HOUR
cache = fieldToLastUpdateDateFn(exalateUser)(changeHistory)
.inject([:]) { r, k, v ->
r[k] = v
v?.date = new Date((((v?.date instanceof Integer) ? v?.date : v?.date?.time) + TIME_DIFF) as Long)
r
}
cache
} else {
cache
}
}
fn
}
and then it’s call later on:
def remoteFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(replica.changeHistory)
def localFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(issue.changeHistory)
became:
def remoteFn = changeDateFnFn("exalate", 0, replica.changeHistory)
def localFn = changeDateFnFn("exalate", 0, issue.changeHistory)
so then the conflict conditions changed from
if(firstSync) {
issue."BBB" = replica."AAA"
} else {
if (remoteFieldToLastUpdateDate."AAA" > localFieldToLastUpdateDate."BBB") {
issue."BBB" = replica."AAA"
}}
to
if(firstSync || remoteFn()?."AAA"?.date > localFn()?."BBB"?.date) {
issue."BBB" = replica."AAA"
}
I also introduced the userStr helper function that converts a user object into a read-able text:
def userStr = { usr ->
(usr == null) ?
"Unknown user" :
"${usr?.displayName} (${usr?.email})".toString()
}
Chapter 3: all code together
Outgoing on source:
// community 58501849 send history information to help with the reasoning
replica.changeHistory = issue.changeHistory
replica.created = issue.created
Incoming on destination:
def changeLogBuilder = new StringBuilder();
def userStr = { usr ->
(usr == null) ?
"Unknown user" :
"${usr?.displayName} (${usr?.email})".toString()
}
// community 58501849 start accumulating the change log
def latestChange = replica
.changeHistory
.sort { c -> c.created.time }
.reverse()
.find()
def latestChangeMsg = (latestChange == null) ?
"${replica.key} created by ${userStr(replica.reporter)} on ${replica.created}" :
"${latestChange.changeItems.field.join(", ")} updated by ${userStr(latestChange.author)} on ${latestChange.created}"
def syncMsg = firstSync ?
"First sync" :
"Sync"
changeLogBuilder
.append("# $syncMsg triggered due to $latestChangeMsg")
// end: community 58501849 start accumulating the change log
if(firstSync) {
issue.projectKey = "BB"
// community 58501849 add to change log due to issue.projectKey
changeLogBuilder
.append("\n# ")
.append("Setting project to ${issue.projectKey} because it's first sync coming from ${replica.project.key}")
// end: community 58501849 add to change log due to issue.projectKey
issue.typeName = nodeHelper.getIssueType(replica.type?.name, issue.projectKey)?.name ?: "Task"
// community 58501849 add to change log due to issue.typeName
def isSameTypeFound = nodeHelper.getIssueType(replica.type?.name, issue.projectKey)?.name != null
def issueTypeMsg = isSameTypeFound ?
"same issue type as ${replica.type?.name} found in ${issue.projectKey}" :
"issue type ${replica.type?.name} not found in ${issue.projectKey} "+
"so Exalate assigns a default issue type \"Task\""
changeLogBuilder
.append("\n# ")
.append(
"Setting issue type to ${issue.typeName} because "+
"it's first sync coming from ${replica.project.key} "+
"and $issueTypeMsg".toString()
)
// end: community 58501849 add to change log due to issue.typeName
}
// community 58501849 + 58500894 conflict handling + useful info for change reasons
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] = ["date":c.created,"who":c.author]
}
r
}
}
}}
def changeDateFnFn = { exalateUser, timeDiffHours, changeHistory ->
def cache = null
def fn = { ->
if(cache == null) {
def HOUR = 1000 * 60 * 60
def TIME_DIFF = timeDiffHours * HOUR
cache = fieldToLastUpdateDateFn(exalateUser)(changeHistory)
.inject([:]) { r, k, v ->
r[k] = v
v?.date = new Date((((v?.date instanceof Integer) ? v?.date : v?.date?.time) + TIME_DIFF) as Long)
r
}
cache
} else {
cache
}
}
fn
}
def remoteFn = changeDateFnFn("557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec", 0, replica.changeHistory) // 557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec is the exalate's user id for Jira Cloud
def localFn = changeDateFnFn("557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec", 0, issue.changeHistory) // 557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec is the exalate's user id for Jira Cloud
// end: community 58501849 + 58500894 conflict handling + useful info for change reasons
issue.comments = commentHelper.mergeComments(issue, replica)
// community 58501849 logging comment changes into the change log
if (issue.comments != issueBeforeScript.comments) {
if (!replica.removedComments.empty) {
changeLogBuilder
.append("\n# ")
.append(
"There were ${replica.removedComments.size()} comments removed on the remote side, so Exalate is going to remove them here too".toString()
)
}
if (!replica.addedComments.empty) {
changeLogBuilder
.append("\n# ")
.append(
"There were ${replica.addedComments.size()} comments added on the remote side by ${replica.addedComments.collect { userStr(it?.author) }.join(", ")}, so Exalate is going to add them here too".toString()
)
}
}
// end: community 58501849 logging comment changes into the change log
issue.attachments = attachmentHelper.mergeAttachments(issue, replica)
// community 58501849 logging attachment changes into the change log
if (issue.attachments != issueBeforeScript.attachments) {
if (!replica.removedAttachments.empty) {
changeLogBuilder
.append("\n# ")
.append(
"There were ${replica.removedComments.size()} attachments removed on the remote side, so Exalate is going to remove them here too".toString()
)
}
if (!replica.addedAttachments.empty) {
changeLogBuilder
.append("\n# ")
.append(
"There were ${replica.addedAttachments.size()} attachments added on the remote side by ${replica.addedAttachments.collect { userStr(it?.author) }.join(", ")}, so Exalate is going to add them here too".toString()
)
}
}
// end: community 58501849 logging attachment changes into the change log
issue.labels = replica.labels
// community 58501849 logging label changes into the change log
if (issue.labels != issueBeforeScript.labels) {
changeLogBuilder
.append("\n# ")
.append(
"Overwriting labels because Exalate is configured to do it on every sync"
)
}
// end: community 58501849 logging label changes into the change log
// community 58501849 + 58500894 conflict handling = logging why was a field changed or left untouched
if (firstSync || remoteFn()?."description"?.date > localFn()?."description"?.date) {
issue.description = replica.description
def reason = firstSync ?
"it's first sync, and there is no local value yet" :
"it was changed by ${userStr(remoteFn()?."description"?.who)} "+
"more recently (${remoteFn()?."description"?.date}) on remote side "+
"than on local side (${localFn()?."description"?.date})"
changeLogBuilder
.append("\n# ")
.append(
"Overwriting description because $reason".toString()
)
} else {
changeLogBuilder
.append("\n# ")
.append(
"Not touching \"description\" because "+
"it is not the first sync and "+
"it was changed by ${userStr(localFn()?."description"?.who)} "+
"more recently (${localFn()?."description"?.date}) locally "+
"than on the remote side (${remoteFn()?."description"?.date})".toString()
)
}
if (firstSync || remoteFn()."summary"?.date > localFn()."summary"?.date) {
issue.summary = replica.summary
def reason = firstSync ?
"it's first sync, and there is no local value yet" :
"it was changed by ${userStr(remoteFn()?."summary"?.who)} "+
"more recently (${remoteFn()?."summary"?.date}) on remote side "+
"than on local side (${localFn()?."summary"?.date})"
changeLogBuilder
.append("\n# ")
.append(
"Overwriting \"summary\" because $reason".toString()
)
} else {
changeLogBuilder
.append("\n# ")
.append(
"Not touching \"summary\" because "+
"it is not the first sync and "+
"it was changed by ${userStr(localFn()?."summary"?.who)} "+
"more recently (${localFn()?."summary"?.date}) locally "+
"than on the remote side (${remoteFn()?."summary"?.date})".toString()
)
}
if (firstSync || remoteFn()?."Classification"?.date > remoteFn()?."Classification"?.date) {
issue.Classification = replica.Classification
def reason = firstSync ?
"it's first sync, and there is no local value yet" :
"it was changed by ${userStr(remoteFn()?."Classification"?.who)} "+
"more recently (${remoteFn()?."Classification"?.date}) on remote side "+
"than on local side (${localFn()?."Classification"?.date})"
changeLogBuilder
.append("\n# ")
.append(
"Overwriting \"Classification\" because $reason".toString()
)
} else {
changeLogBuilder
.append("\n# ")
.append(
"Not touching \"Classification\" because "+
"it is not the first sync and "+
"it was changed by ${userStr(localFn()?."Classification"?.who)} "+
"more recently (${localFn()?."Classification"?.date}) locally "+
"than on the remote side (${remoteFn()?."Classification"?.date})".toString()
)
}
// end: community 58501849 + 58500894 conflict handling = logging why was a field changed or left untouched
// community 58501849 adding the change log to a custom field
issue."Exalate Change Reason" = changeLogBuilder.toString()
// end: community 58501849 adding the change log to a custom field
Let me know how it goes,
Happy Exalating!