2
1
0

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

  1. Serhiy Onyshchenko

    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.

CommentAdd your comment...

1 answer

  1.  
    2
    1
    0

    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...