News feed for ongoing Exalate updates

Originally asked by Guy Toyber on 11 May 2023 (original question)


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


Comments:

Serhiy Onyshchenko commented on 12 May 2023

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:

  • adding a comment
  • modifying a custom field called “Exalate Change Reason”

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.

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!


This topic was automatically closed 2 days after the last reply. New replies are no longer allowed.