1 answer
- 210
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),
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) 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!CommentAdd your comment...
Hi there,
I would like to know if it will be possible to create some custom field to include a news-feed that will update all Exalate sync events.
The sync could include some metadata, like the user who trigger the sync; time stamp ; the custom field and more.
This feature will increase the clarity about the sync process for all end users who would like to know why a field was change and who did it.
Is it something that you can support?
Regards,
Guy
Hey, Guy Toyber ,
Thanks for raising this on community,
While the audit log feature is being considered by our product team, I think we could work it around by either:
each time that Exalate is processing an incoming sync with some text clarifying the reasons why it did so (taken from the change history of the remote side).
Let me come back to you with an answer next Tuesday May 16th 2023.
Regards, Serhiy.