2
1
0

As in summary, if two updates happen around the same time in ADO (let's say, description is updated) and Jira (let's same summary is updated) I want Exalate to make sure that both sides have the latest summary and latest description.
I also want to make it that if description is modified on Jira first and a couple of seconds after that it is modified on ADO, both sides see the description from ADO. And visa-versa: if description is modified on ADO first and then in a couple of seconds, it is modified on Jira, I want both sides to see description from Jira.

    CommentAdd your comment...

    6 answers

    1.  
      3
      2
      1

      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:


      JiraADO
      ​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:


      JiraADO
      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:

      1. we make a REST API request to ADO:
        GET /${project}/_apis/wit/workItems/${workItem.id}/updates?api-version=5.0
      2. results of which we convert into workItem.changeHistory
      3. and then we use the fieldToLastUpdateDateFn to convert the change history into a simplified version of the change history
      4. 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


      JiraADO
      ​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


      JiraADO
      ​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:


      JiraADO
      ​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


      JiraADO
      ​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:


      JiraADO
      ​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!

        CommentAdd your comment...
      1.  
        3
        2
        1

        Some notes for Servicenow:


        Snow
        ​Out

        def result = httpClient
                .http(
                "GET", 
                "/api/now/v2/table/sys_audit", 
                 null,
        //"""{"field":"value"}""", 
                 ["sysparm_query" : ["documentkey=${entity.sys_id}".toString()]], 
                 ["Accept":["application/json"]]) { request, response -> 
                 if (response.code == 200) {
                    def body = response.body
                    //def js = new groovy.json.JsonSlurper()
                    //def json = js.parseText(body)
                    //json
                    body
                }      
            else throw new Exception("Failed with ${response.code} and body: ${response.body}")
                }
        
        def dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")//2022-07-13 15:16:02
        def noMsDateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss")//2022-07-13 15:16:02
        
        
        
         entity.changeHistory = result.result.inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory>) { _res, hJson ->
            if (hJson.sys_created_on) {
                def author = new com.exalate.basic.domain.hubobject.v1.BasicHubUser()
                author.key = hJson.user
                def date = ({ dStr ->
                    if (dStr == null) return null
                    try { noMsDateFormat.parse(dStr) }
                    catch (e1) { dateFormat.parse(dStr) }
                })(hJson?.sys_created_on)
                def timestamp = date == null ? null : new java.sql.Timestamp(date.time)
                def changeItems = [
                    new com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem(hJson.oldvalue as String, hJson.oldvalue as String, hJson.newvalue as String, hJson.newvalue as String, hJson.fieldname, "system")
                    
                 ]
                _res += new com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory(-1, 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")(entity.changeHistory)
        In
        def result = httpClient
                .http(
                "GET", 
                "/api/now/v2/table/sys_audit", 
                 null,
        //"""{"field":"value"}""", 
                 ["sysparm_query" : ["documentkey=${entity.sys_id}".toString()]], 
                 ["Accept":["application/json"]]) { request, response -> 
                 if (response.code == 200) {
                    def body = response.body
                    //def js = new groovy.json.JsonSlurper()
                    //def json = js.parseText(body)
                    //json
                    body
                }      
            else throw new Exception("Failed with ${response.code} and body: ${response.body}")
                }
        
        def dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")//2022-07-13 15:16:02
        def noMsDateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd HH:mm:ss")//2022-07-13 15:16:02
        
        
        
         entity.changeHistory = result.result.inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory>) { _res, hJson ->
            if (hJson.sys_created_on) {
                def author = new com.exalate.basic.domain.hubobject.v1.BasicHubUser()
                author.key = hJson.user
                def date = ({ dStr ->
                    if (dStr == null) return null
                    try { noMsDateFormat.parse(dStr) }
                    catch (e1) { dateFormat.parse(dStr) }
                })(hJson?.sys_created_on)
                def timestamp = date == null ? null : new java.sql.Timestamp(date.time)
                def changeItems = [
                    new com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem(hJson.oldvalue as String, hJson.oldvalue as String, hJson.newvalue as String, hJson.newvalue as String, hJson.fieldname, "system")
                    
                 ]
                _res += new com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory(-1, 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 = 0 * HOUR
            def remoteFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(replica.changeHistory)
            def localFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(entity.changeHistory)
            localFieldToLastUpdateDate = localFieldToLastUpdateDate.inject([:]) { r, k, v -> 
                r[k] = new Date(v.time + THREE_HOURS)
                r
            }
            if (remoteFieldToLastUpdateDate."description" > localFieldToLastUpdateDate."description") {
                entity.description = replica.description
            }
            if (remoteFieldToLastUpdateDate."summary" > localFieldToLastUpdateDate."short_description") {
                issue.summary = replica.summary
            }
        }   
          CommentAdd your comment...
        1.  
          2
          1
          0

          For Jira <> Jira:


          Jira AJira B
          ​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 = []
           
          replica.customFields.Severity = issue.customFields.Severity
          
          // Community 42839472: Avoid updating issue with older changes Jira <> Jira
          replica.changeHistory = issue.changeHistory
          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 = []
           
          replica.customFields.Severity = issue.customFields.Severity
          
          // Community 42839472: Avoid updating issue with older changes Jira <> Jira
          replica.changeHistory = issue.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 Jira <> 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 = fieldToLastUpdateDateFn("exalate")(replica.changeHistory)
              remoteFieldToLastUpdateDate = remoteFieldToLastUpdateDate.inject([:]) { r, k, v ->
                  r[k] = new Date((v + THREE_HOURS) as Long)
                  r
              }
              def localFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(issue.changeHistory)
              if (remoteFieldToLastUpdateDate."description" > localFieldToLastUpdateDate."description") {
                  issue.description = replica.description
              }
              if (remoteFieldToLastUpdateDate."summary" > localFieldToLastUpdateDate."summary") {
                  issue.summary = replica.summary
              }
              if (remoteFieldToLastUpdateDate."Severity" > localFieldToLastUpdateDate."Severity") {         issue.Severity = replica.Severity    
            }
          }
          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 Jira <> 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 = fieldToLastUpdateDateFn("exalate")(replica.changeHistory)
              remoteFieldToLastUpdateDate = remoteFieldToLastUpdateDate.inject([:]) { r, k, v ->
                  r[k] = new Date((v - THREE_HOURS) as Long)
                  r
              }
              def localFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(issue.changeHistory)
              if (remoteFieldToLastUpdateDate."description" > localFieldToLastUpdateDate."description") {
                  issue.description = replica.description
              }
              if (remoteFieldToLastUpdateDate."summary" > localFieldToLastUpdateDate."summary") {
                  issue.summary = replica.summary
              }
              if (remoteFieldToLastUpdateDate."Severity" > localFieldToLastUpdateDate."Severity") {         issue.Severity = replica.Severity    
            }
          }

          A note about time

          In my environments, there's a 3h difference between how Jira A and Jira report changeHistory dates:

          in Jira A all the dates seem to be matching my Jira's time zone (EEST), while Jira B reports everything in UTC.

          So if I make changes on Jira A at 12 AM GMT and in Jira B 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 Jira A and subtracts them for Jira B:

          To accomodate for that, I made it that the incoming scripts add 3 hours to ADO:


          Jira AJira B
          ​In

          // Community 42839472: Avoid updating issue with older changes Jira <> 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 Jira <> 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.

          Regards, Serhiy

            CommentAdd your comment...
          1.  
            2
            1
            0

            In the new version(Exalate v. 5.4.0 (Core v. 5.4.4)) this code line stop working for ADO and it couldn't find the project: 

            def project = connection.trackerSettings.fieldValues."project"

            So I solved this just by changing the code above with this:

            def project = workItem.projectKey
            1. Serhiy Onyshchenko

              Thank you very much for contributing, Oleksandra Honcharenko ,

              scripts updated!

            CommentAdd your comment...
          2.  
            1
            0
            -1

            Hello there,
            Here's a script for

            ZenDesk <> Jira

            conflict handling

            ZD Out

            replica.key          = issue.key
            replica.assignee     = issue.assignee
            replica.reporter     = issue.reporter
            replica.summary      = issue.summary
            replica.description  = issue.description
            replica.type         = issue.type
            replica.labels       = issue.labels
            replica.attachments  = issue.attachments
            replica.comments     = issue.comments
            replica.status       = issue.status
            
            // Community 42839472: Avoid updating issue with older changes ZD <> *
            def allAudits = []
            def getPage = { String nextPageUrl ->
                httpClient
                    // .http(
                    //     "GET",
                    //     "/api/v2/tickets/${ticket.id.toString()}/audits".toString(),
                    // )
                    .get(nextPageUrl)
            }
            boolean isLastPage = false
            String nextPageUrlStr = "/api/v2/tickets/${ticket.id.toString()}/audits".toString()
            while(!isLastPage){
                def result = getPage(nextPageUrlStr)
                if(result.audits && result.audits.size() > 0) {
                    allAudits.addAll(result.audits)
                }
                isLastPage = result.next_page == null
                nextPageUrlStr = result.next_page
            }
            // we collected all audits in one list: allAudits
            
            def noMsDateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")//"2021-10-05T12:33:10Z"
            ticket.changeHistory = allAudits
                .inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory>) { _res, hJson ->
                    def author = new com.exalate.basic.domain.hubobject.v1.BasicHubUser()
                    author.key = hJson.author_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 { noMsDateFormat.parse(dStr) }
                        catch (e1) { return null }
                    })(hJson.created_at)
                    def timestamp = date == null ? null : new java.sql.Timestamp(date.time)
                    def changeItems = hJson
                        .events
                        ?.findAll { event -> ["Create", "Change"].any { it.equals(event.type) } }
                        ?.inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem>) { r, v ->
                            def ci = new com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem(
                                v.previous_value as String,
                                v.previous_value as String,
                                v.value as String,
                                v.value as String,
                                v.field_name,
                                ("\\d+".matches(v.field_name) ?
                                    "custom" :
                                    "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("377510125653")(ticket.changeHistory)// END: Community 42839472: Avoid updating issue with older changes ZD <> *

            Note, the changes made by Exalate proxy user are ignored:

            fieldToLastUpdateDateFn("377510125653")(ticket.changeHistory)
            377510125653 - the exalate proxy user's id in this Zendesk instance

            ZD In

            // Community 42839472: Avoid updating issue with older changes ZD <> *
            def allAudits = []
            def getPage = { String nextPageUrl ->
                httpClient
                    // .http(
                    //     "GET",
                    //     "/api/v2/tickets/${ticket.id.toString()}/audits".toString(),
                    // )
                    .get(nextPageUrl)
            }
            boolean isLastPage = false
            String nextPageUrlStr = "/api/v2/tickets/${ticket.id.toString()}/audits".toString()
            while(!isLastPage){
                def result = getPage(nextPageUrlStr)
                if(result.audits && result.audits.size() > 0) {
                    allAudits.addAll(result.audits)
                }
                isLastPage = result.next_page == null
                nextPageUrlStr = result.next_page
            }
            // we collected all audits in one list: allAudits
            
            def noMsDateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")//"2021-10-05T12:33:10Z"
            ticket.changeHistory = allAudits
                .inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeHistory>) { _res, hJson ->
                    def author = new com.exalate.basic.domain.hubobject.v1.BasicHubUser()
                    author.key = hJson.author_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 { noMsDateFormat.parse(dStr) }
                        catch (e1) { return null }
                    })(hJson.created_at)
                    def timestamp = date == null ? null : new java.sql.Timestamp(date.time)
                    def changeItems = hJson
                        .events
                        ?.findAll { event -> ["Create", "Change"].any { it.equals(event.type) } }
                        ?.inject([] as List<com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem>) { r, v ->
                            def ci = new com.exalate.basic.domain.hubobject.v1.BasicHubChangeItem(
                                v.previous_value as String,
                                v.previous_value as String,
                                v.value as String,
                                v.value as String,
                                v.field_name,
                                ("\\d+".matches(v.field_name) ?
                                    "custom" :
                                    "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 localFieldToLastUpdateDate = fieldToLastUpdateDateFn("377510125653")(ticket.changeHistory)
            def remoteFieldToLastUpdateDate = replica.customKeys."fieldToLastUpdateDate"
            remoteFieldToLastUpdateDate = remoteFieldToLastUpdateDate.inject([:]) { r, k, v ->
                r[k] = new Date((v) as Long)
                r
            }
            
            //issue.labels       = replica.labels
            
            if (firstSync || (remoteFieldToLastUpdateDate."summary" > localFieldToLastUpdateDate."subject")) {
              log.error("#conflict_handling: summary changed more recently on remote: ${remoteFieldToLastUpdateDate.summary} is MORE recent then ${localFieldToLastUpdateDate."subject"}".toString())
              issue.summary      = replica.summary
            } else {
              log.error("#conflict_handling: summary NOT changed more recently on remote: ${remoteFieldToLastUpdateDate.summary} is LESS recent then ${localFieldToLastUpdateDate."subject"}".toString())
            }
            if(firstSync) {
              issue.description  = replica.description ?: "No description."
            }
            
            if (remoteFieldToLastUpdateDate."status" > localFieldToLastUpdateDate."status") {
                def statusMapping = [
                    "To Do" : "new",
                    "In Progress" : "open",
                    "Pending on customer" : "pending",
                    "Waiting for support" : "hold",
                    "Done" : "solved",
                    "Cancelled" : "solved",
                    "Completed" : "solved"
                ]
                def localStatus = statusMapping
                  .find { k, v -> k.equalsIgnoreCase(replica.status.name) }
                  ?.value
                
              log.error("#conflict_handling: status changed more recently on remote: ${remoteFieldToLastUpdateDate.status} is MORE recent then ${localFieldToLastUpdateDate.status}".toString())
              log.error("#in found local status $localStatus in mapping $statusMapping for remote status ${replica.status.name}".toString())
              if (localStatus) {
                ticket.setStatus(localStatus)
              }
            } else {
                log.error("#conflict_handling: status NOT changed more recently on remote: ${remoteFieldToLastUpdateDate.status} is LESS recent then ${localFieldToLastUpdateDate.status}".toString())
            }
            // END: Community 42839472: Avoid updating issue with older changes ZD <> *
            issue.attachments  = attachmentHelper.mergeAttachments(issue, replica)
            issue.comments     += replica.addedComments
            
            

            Note, the changes made by Exalate proxy user are ignored:

            fieldToLastUpdateDateFn("377510125653")(ticket.changeHistory)
            377510125653 - the exalate proxy user's id in this Zendesk instance

            Jira 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
            replica.project.versions = []
            replica.project.components = []// Community 42839472: Avoid updating issue with older changes 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
                            }
                        }
            }}
            replica.customKeys."fieldToLastUpdateDate" = fieldToLastUpdateDateFn("557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec")(issue.changeHistory)// END: Community 42839472: Avoid updating issue with older changes Jira <> *

            Note, that changes made by Exalate proxy user in Jira Cloud are ignored:

            fieldToLastUpdateDateFn("557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec")(issue.changeHistory)
            557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec - is the same for any Jira Cloud

            Jira In:

            if (firstSync) {
                issue.projectKey  = "AA"
                // Set the same issue type as the source issue. If not found, set a default.
                issue.typeName    = nodeHelper.getIssueType(replica.type?.name, issue.projectKey)?.name ?: "Task"
            }
            // Community 42839472: Avoid updating issue with older changes Jira <> *
            def remoteFieldToLastUpdateDate = replica.customKeys."fieldToLastUpdateDate"
            remoteFieldToLastUpdateDate = remoteFieldToLastUpdateDate.inject([:]) { r, k, v ->
                r[k] = new Date((v) as Long)
                r
            }
            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 localFieldToLastUpdateDate = fieldToLastUpdateDateFn("557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec")(issue.changeHistory)
                
            if (firstSync || (remoteFieldToLastUpdateDate."subject" > localFieldToLastUpdateDate.summary)) {
              log.error("#conflict_handling: summary changed more recently on remote: ${remoteFieldToLastUpdateDate."subject"} is MORE recent then ${localFieldToLastUpdateDate.summary}".toString())
              issue.summary      = replica.summary
            } else {
              log.error("#conflict_handling: summary NOT changed more recently on remote: ${remoteFieldToLastUpdateDate."subject"} is LESS recent then ${localFieldToLastUpdateDate.summary}".toString())
            }
            if(firstSync || (remoteFieldToLastUpdateDate."description" > localFieldToLastUpdateDate."description")) {
              log.error("#conflict_handling: description changed more recently on remote: ${remoteFieldToLastUpdateDate.description} is MORE recent then ${localFieldToLastUpdateDate.description}".toString())
              issue.description  = replica.description
            } else {
              log.error("#conflict_handling: description NOT changed more recently on remote: ${remoteFieldToLastUpdateDate.description} is LESS recent then ${localFieldToLastUpdateDate.description}".toString())
            }
            
            if (remoteFieldToLastUpdateDate."status" > localFieldToLastUpdateDate."status") {
                def statusMapping = [
                    "new" : "To Do",
                    "open" : "In Progress",
                    "pending" : "Pending on customer",
                    "on-hold" : "Waiting for support",
                    "solved" : "Completed"
                ]
                def localStatus = statusMapping
                  .find { k, v -> k.equalsIgnoreCase(replica.status.name) }
                  ?.value
                
              log.error("#conflict_handling: status changed more recently on remote: ${remoteFieldToLastUpdateDate.status} is MORE recent then ${localFieldToLastUpdateDate.status}".toString())
              log.error("#in found local status $localStatus in mapping $statusMapping for remote status ${replica.status.name}".toString())
              if (localStatus) {
                ticket.setStatus(localStatus)
              }
            } else {
                log.error("#conflict_handling: status NOT changed more recently on remote: ${remoteFieldToLastUpdateDate.status} is LESS recent then ${localFieldToLastUpdateDate.status}".toString())
            }
            // END: Community 42839472: Avoid updating issue with older changes Jira <> *
            issue.comments     = commentHelper.mergeComments(issue, replica)
            issue.attachments  = attachmentHelper.mergeAttachments(issue, replica)

            Note, that changes made by Exalate proxy user in Jira Cloud are ignored:

            fieldToLastUpdateDateFn("557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec")(issue.changeHistory)
            557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec - is the same for any Jira Cloud


            Happy Exalating!
            Serhiy

              CommentAdd your comment...
            1.  
              1
              0
              -1

              Ezequiel Consorti Serhiy Onyshchenko Hello!


              Can you please help me understand In the Jira<>Jira script, where do i need to make modifications (on the incoming section)?


              We've tried to implement it yesterday and gained an error message (I've added the following script as a block in the incoming of both sides, and set the relevant configuration for the outgoing section as well as descripted in the shared script):


              I didn't copy the IF FIRST SYNC as I've just used our used condition that relevant for our project.



                  
              // Community 42839472: Avoid updating issue with older changes Jira <> 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 = fieldToLastUpdateDateFn("exalate")(replica.changeHistory)
                  remoteFieldToLastUpdateDate = remoteFieldToLastUpdateDate.inject([:]) { r, k, v ->
                      r[k] = new Date((v + THREE_HOURS) as Long)
                      r
                  }
                  def localFieldToLastUpdateDate = fieldToLastUpdateDateFn("exalate")(issue.changeHistory)
                  if (remoteFieldToLastUpdateDate."description" > localFieldToLastUpdateDate."description") {
                      issue.description = replica.description
                  }
                  if (remoteFieldToLastUpdateDate."summary" > localFieldToLastUpdateDate."summary") {
                      issue.summary = replica.summary
                  }
                  if (remoteFieldToLastUpdateDate."Severity" > localFieldToLastUpdateDate."Severity") {         issue.Severity = replica.Severity   
                }
              }



              1. Ezequiel Consorti

                Hi Sagui, 


                There is a part of the script that is throwing the error: 

                remoteFieldToLastUpdateDate = remoteFieldToLastUpdateDate.inject([:]) { r, k, v ->
                        r[k] = new Date((v + THREE_HOURS) as Long)
                        r
                    }

                Please replace it with the following script which has been tested already:

                remoteFieldToLastUpdateDate = remoteFieldToLastUpdateDate.inject([:]) { r, k, v ->
                        r[k] = new Date((((v instanceof Integer) ? v : v.time) + THREE_HOURS) as Long)
                        r
                }
              CommentAdd your comment...