ADO Jira Server - Maintaining issue hierarchy during sync

JIRA Outgoing.groovy
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 = []

/*
Custom Fields

replica.customFields."CF Name" = issue.customFields."CF Name"
*/
JIRA Incoming.groovy
import com.atlassian.jira.issue.link.IssueLinkManager
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.security.JiraAuthenticationContext
import com.atlassian.jira.issue.link.IssueLinkTypeManager
import com.atlassian.jira.issue.link.IssueLinkType
import org.slf4j.Logger

class LogIn {

        static logIn(u) {
            def authCtx = com.atlassian.jira.component.ComponentAccessor.getJiraAuthenticationContext()
            try {
                //Jira 7
                authCtx.setLoggedInUser(u)
            } catch (Exception ignore) {
                // Jira 6
                //noinspection GroovyAssignabilityCheck
                authCtx.setLoggedInUser(u.getDirectoryUser())
            }
        }

        static <R> R tryLogInFinallyLogOut(Closure<R> fn) {
            def authCtx = com.atlassian.jira.component.ComponentAccessor.getJiraAuthenticationContext()
            def proxyAppUser = getProxyUser()
            def loggedInUser = authCtx.getLoggedInUser()
            try {
                logIn(proxyAppUser)
                fn()
            } finally {
                logIn(loggedInUser)
            }
        }

        static getProxyUser() {
            def nserv = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.node.INodeService.class)
            nserv.proxyUser
        }

    }
class CreateIssue {

        static def log = org.slf4j.LoggerFactory.getLogger("com.exalate.scripts.Epic")

        private static def doCreate = {
            com.exalate.basic.domain.hubobject.v1.BasicHubIssue replica,
            com.exalate.basic.domain.hubobject.v1.BasicHubIssue issue,
            com.exalate.api.domain.request.ISyncRequest syncRequest,
            com.exalate.node.hubobject.v1_3.NodeHelper nodeHelper,
            com.exalate.basic.domain.hubobject.v1.BasicHubIssue issueBeforeScript,
            com.exalate.api.domain.INonPersistentReplica remoteReplica,
            List<com.exalate.api.domain.twintrace.INonPersistentTrace> traces,
            List<com.exalate.api.domain.IBlobMetadata> blobMetadataList,
            Logger log ->
                def firstSync = com.exalate.processor.jira.JiraCreateIssueProcessor.createProcessorContext.get() == true
                def issueLevelError = { String msg ->
                    new com.exalate.api.exception.IssueTrackerException(msg)
                }
                def issueLevelError2 = { String msg, Throwable c ->
                    new com.exalate.api.exception.IssueTrackerException(msg, c)
                }
                def toExIssueKey = { com.atlassian.jira.issue.MutableIssue i ->
                    new com.exalate.basic.domain.BasicIssueKey(i.id, i.key)
                }
                final def authCtxInternal = com.atlassian.jira.component.ComponentAccessor.getJiraAuthenticationContext()
                final def imInternal = com.atlassian.jira.component.ComponentAccessor.issueManager
                final def umInternal = com.atlassian.jira.component.ComponentAccessor.userManager
                final def nservInternal = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.node.INodeService.class)

                final def hohfInternal2 = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.hubobject.IHubObjectHelperFactory.class)
                //noinspection GroovyAssignabilityCheck
                final def hohInternal2 = hohfInternal2.get(remoteReplica.payload.version)

                if (issue.id != null) {
                    def existingIssue = imInternal.getIssueObject(issue.id as Long)
                    if (existingIssue != null) {
                        return [existingIssue, toExIssueKey(existingIssue)]
                    }
                }

                def proxyAppUserInternal = nservInternal.getProxyUser()

                def loggedInUser = authCtxInternal.getLoggedInUser()

                log.debug("Logged user is " + loggedInUser)

                def reporterAppUser = null
                if (issue.reporter != null) {
                    reporterAppUser = umInternal.getUserByKey(issue.reporter?.key)
                }
                reporterAppUser = reporterAppUser ?: proxyAppUserInternal

                issue.project = issue.project ?: ({ nodeHelper.getProject(issue.projectKey) })()
                issue.type = issue.type ?: ({ nodeHelper.getIssueType(issue.typeName) })()

                def jIssueInternal = null
                try {
                    LogIn.logIn(reporterAppUser)

                    if (issue.id != null) {
                        def existingIssue = imInternal.getIssueObject(issue.id as Long)
                        if (existingIssue != null) {
                            issue.id = existingIssue.id
                            issue.key = existingIssue.key
                            return [existingIssue, toExIssueKey(existingIssue)]
                        }
                    }
                    def cir
                    try{
                        cir = hohInternal2.createNodeIssueWith(issue, hohInternal2.createHubIssueTemplate(), null, [:], blobMetadataList, syncRequest)
                    } catch (MissingMethodException e){
                        cir = hohInternal2.createNodeIssueWith(issue, hohInternal2.createHubIssueTemplate(), null, [:], blobMetadataList, syncRequest.getConnection())
                    }

                    def createdIssueKey = cir.getIssueKey();

                    jIssueInternal = imInternal.getIssueObject(createdIssueKey.id)

                    if (issue.id != null) {
                        def oldIssueKey = jIssueInternal.key
                        def oldIssueId = jIssueInternal.id
                        try {
                            jIssueInternal.key = issue.key
                            jIssueInternal.store()
                        } catch (Exception e) {
                            log.error("""Failed to sync issue key: ${e.message}. Please contact Exalate Support. Deleting issue `$oldIssueKey` ($oldIssueId)""".toString(), e)
                            imInternal.deleteIssue(proxyAppUserInternal, jIssueInternal as com.atlassian.jira.issue.Issue, com.atlassian.jira.event.type.EventDispatchOption.ISSUE_DELETED, false)
                        }
                    }

                    issue.id = jIssueInternal.id
                    issue.key = jIssueInternal.key

                    return [jIssueInternal, toExIssueKey(jIssueInternal)]
                } catch (com.exalate.api.exception.IssueTrackerException ite) {
                    if (firstSync && jIssueInternal != null) {
                        imInternal.deleteIssue(proxyAppUserInternal, jIssueInternal as com.atlassian.jira.issue.Issue, com.atlassian.jira.event.type.EventDispatchOption.ISSUE_DELETED, false)
                    }
                    throw ite
                } catch (Exception e) {
                    if (firstSync && jIssueInternal != null) {
                        imInternal.deleteIssue(proxyAppUserInternal, jIssueInternal as com.atlassian.jira.issue.Issue, com.atlassian.jira.event.type.EventDispatchOption.ISSUE_DELETED, false)
                    }
                    throw issueLevelError2("""Failed to create issue: ${
                        e.message
                    }. Please review the script or contact Exalate Support""".toString(), e)
                } finally {
                   LogIn.logIn(loggedInUser)
                }
        }

        /**
         * @param whenIssueCreatedFn - a callback closure executed after the issue has been created
         * */
        static com.exalate.basic.domain.BasicIssueKey create(
                com.exalate.basic.domain.hubobject.v1.BasicHubIssue replica,
                com.exalate.basic.domain.hubobject.v1.BasicHubIssue issue,
                com.exalate.api.domain.request.ISyncRequest syncRequest,
                com.exalate.node.hubobject.v1_3.NodeHelper nodeHelper,
                com.exalate.basic.domain.hubobject.v1.BasicHubIssue issueBeforeScript,
                com.exalate.api.domain.INonPersistentReplica remoteReplica,
                List<com.exalate.api.domain.twintrace.INonPersistentTrace> traces,
                List<com.exalate.api.domain.IBlobMetadata> blobMetadataList,
                Closure<?> whenIssueCreatedFn) {

            def firstSync = com.exalate.processor.jira.JiraCreateIssueProcessor.createProcessorContext.get() == true
            def (_jIssue, _exIssueKey) = doCreate(replica, issue, syncRequest, nodeHelper, issueBeforeScript, remoteReplica, traces, blobMetadataList, log)
            com.atlassian.jira.issue.MutableIssue jIssue = _jIssue as com.atlassian.jira.issue.MutableIssue
            com.exalate.basic.domain.BasicIssueKey exIssueKey = _exIssueKey as com.exalate.basic.domain.BasicIssueKey
            try {

                whenIssueCreatedFn()
                UpdateIssue.update(replica, issue, syncRequest, nodeHelper, issueBeforeScript, traces, blobMetadataList, jIssue, exIssueKey)
            } catch (Exception e3) {
                final def imInternal = com.atlassian.jira.component.ComponentAccessor.issueManager
                final def nservInternal2 = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.node.INodeService.class)
                def proxyAppUserInternal = nservInternal2.getProxyUser()
                if (firstSync && _jIssue != null) {
                    imInternal.deleteIssue(proxyAppUserInternal, _jIssue as com.atlassian.jira.issue.Issue, com.atlassian.jira.event.type.EventDispatchOption.ISSUE_DELETED, false)
                }
                throw e3
            }
            return exIssueKey
        }
    }
class UpdateIssue {

        private static def log = org.slf4j.LoggerFactory.getLogger("com.exalate.scripts.Epic")

        private static def doUpdate = { com.exalate.basic.domain.hubobject.v1.BasicHubIssue replica,
                                        com.exalate.basic.domain.hubobject.v1.BasicHubIssue issue,
                                        com.exalate.api.domain.request.ISyncRequest syncRequest,
                                        com.exalate.node.hubobject.v1_3.NodeHelper nodeHelper,
                                        com.exalate.basic.domain.hubobject.v1.BasicHubIssue issueBeforeScript,
                                        List<com.exalate.api.domain.twintrace.INonPersistentTrace> traces,
                                        List<com.exalate.api.domain.IBlobMetadata> blobMetadataList,
                                        com.atlassian.jira.issue.MutableIssue jIssue,
                                        com.exalate.basic.domain.BasicIssueKey exIssueKey ->
            try {
                final def hohfInternal2 = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.hubobject.IHubObjectHelperFactory.class)
                //noinspection GroovyAssignabilityCheck
                final def hohInternal2 = hohfInternal2.get("1.2.0")
                final def nservInternal2 = com.atlassian.jira.component.ComponentAccessor.getOSGiComponentInstanceOfType(com.exalate.api.node.INodeService.class)

                def proxyAppUserInternal2 = nservInternal2.getProxyUser()

                log.info("performing the update for the issue `" + jIssue.key + "` for remote issue `" + replica.key + "`")
                //finally create all
                def fakeTraces2 = com.exalate.util.TraceUtils.indexFakeTraces(traces)
                def preparedIssue2 = hohInternal2.prepareLocalHubIssueForApplication(issueBeforeScript, issue, fakeTraces2)
                //@Nonnull IIssueKey issueKey, @Nonnull IHubIssueReplica hubIssueAfterScripts, @Nullable String proxyUser, @Nonnull IHubIssueReplica hubIssueBeforeScripts, @Nonnull Map<TraceType, List<ITrace>> traces, @Nonnull List<IBlobMetadata> blobMetadataList, IRelation relation
                def resultTraces2
                try{
                    resultTraces2 = hohInternal2.updateNodeIssueWith(exIssueKey, preparedIssue2, proxyAppUserInternal2.key, issueBeforeScript, fakeTraces2, blobMetadataList, syncRequest)
                } catch (MissingMethodException e){
                    resultTraces2 = hohInternal2.updateNodeIssueWith(exIssueKey, preparedIssue2, proxyAppUserInternal2.key, issueBeforeScript, fakeTraces2, blobMetadataList, syncRequest.getConnection())
                }
                traces.clear()
                traces.addAll(resultTraces2 ?: [])
                new Result(issue, traces)
            } catch (com.exalate.api.exception.IssueTrackerException ite2) {
                throw ite2
            } catch (Exception e2) {
                throw new com.exalate.api.exception.IssueTrackerException(e2.message, e2)
            }
        }

        static Result update(com.exalate.basic.domain.hubobject.v1.BasicHubIssue replica,
                             com.exalate.basic.domain.hubobject.v1.BasicHubIssue issue,
                             com.exalate.api.domain.request.ISyncRequest syncRequest,
                             com.exalate.node.hubobject.v1_3.NodeHelper nodeHelper,
                             com.exalate.basic.domain.hubobject.v1.BasicHubIssue issueBeforeScript,
                             List<com.exalate.api.domain.twintrace.INonPersistentTrace> traces,
                             List<com.exalate.api.domain.IBlobMetadata> blobMetadataList,
                             com.atlassian.jira.issue.MutableIssue jIssue,
                             com.exalate.basic.domain.BasicIssueKey exIssueKey) {
            doUpdate(replica, issue, syncRequest, nodeHelper, issueBeforeScript, traces, blobMetadataList, jIssue, exIssueKey)
        }

        static class Result {
            com.exalate.basic.domain.hubobject.v1.BasicHubIssue issue
            List<com.exalate.api.domain.twintrace.INonPersistentTrace> traces

            Result(com.exalate.basic.domain.hubobject.v1.BasicHubIssue issue, java.util.List<com.exalate.api.domain.twintrace.INonPersistentTrace> traces) {
                this.issue = issue
                this.traces = traces
            }
        }
    }
    
int createIssueLink(){
 if (replica.parentId || replica."relation"){
    def parentLinkExists = false
    if (replica.parentId)
        flag = true
    def localParentKey = nodeHelper.getLocalIssueKeyFromRemoteId(replica.parentId ?: replica?."relationid" as Long, "issue")?.key
    if (localParentKey==null) return 1
    final String sourceIssueKey = localParentKey
    final String destinationIssueKey = issue.key
    
    def linkTypeMap = [
        "Parent" : "Relates",
        "Duplicate" : "Duplicate"
        ]
    String issueLinkName

    if (!parentLinkExists)
        issueLinkName = linkTypeMap[replica."relation"]
    else
        issueLinkName = "Blocks"

    final Long sequence = 1L

    def loggedInUser = ComponentAccessor.jiraAuthenticationContext.loggedInUser
    def issueLinkTypeManager = ComponentAccessor.getComponent(IssueLinkTypeManager)
    def issueManager = ComponentAccessor.issueManager
    
    def sourceIssue = issueManager.getIssueByCurrentKey(sourceIssueKey)
    def destinationIssue = issueManager.getIssueByCurrentKey(destinationIssueKey)

    def availableIssueLinkTypes = issueLinkTypeManager.issueLinkTypes
    
    int i,f=999
    for (i=0; i<availableIssueLinkTypes.size() ; i++){
        if(issueLinkName.equals(availableIssueLinkTypes[i].name)){
            f = i
            break
        }
    }
    ComponentAccessor.issueLinkManager.createIssueLink(sourceIssue.id, destinationIssue.id, availableIssueLinkTypes[f].id, sequence, loggedInUser)
  }
  return 1
}    

if(firstSync){
   issue.projectKey   = "PM00" 

    def issueMap = [
        "Epic" :"Story",
        "Feature" :"Task",
        "Task" :"Bug"
        ]
    issue.typeName  = issueMap[replica.type?.name]

    CreateIssue.create(
                    replica,
                    issue,
                    syncRequest,
                    nodeHelper,
                    issueBeforeScript,
                    remoteReplica,
                    traces,
                    blobMetadataList) {
                        if (replica.parentId || replica."relation")
                            createIssueLink()
                    }
            
}

issue.summary      = replica.summary
issue.description  = replica.description
issue.comments     = commentHelper.mergeComments(issue, replica)
issue.attachments  = attachmentHelper.mergeAttachments(issue, replica)
issue.labels       = replica.labels

//if (replica.parentId || replica."relation")
//    createIssueLink()

/*
User Synchronization (Assignee/Reporter)

Set a Reporter/Assignee from the source side, if the user can't be found set a default user
You can use this approach for custom fields of type User
def defaultUser = nodeHelper.getUserByEmail("default@idalko.com")
issue.reporter = nodeHelper.getUserByEmail(replica.reporter?.email) ?: defaultUser
issue.assignee = nodeHelper.getUserByEmail(replica.assignee?.email) ?: defaultUser
*/

/*
Comment Synchronization

Sync comments with the original author if the user exists in the local instance
Remove original Comments sync line if you are using this approach
issue.comments = commentHelper.mergeComments(issue, replica){ it.executor = nodeHelper.getUserByEmail(it.author?.email) }
*/

/*
Status Synchronization

Sync status according to the mapping [remote issue status: local issue status]
If statuses are the same on both sides don't include them in the mapping
def statusMapping = ["Open":"New", "To Do":"Backlog"]
def remoteStatusName = replica.status.name
issue.setStatus(statusMapping[remoteStatusName] ?: remoteStatusName)
*/

/*
Custom Fields

This line will sync Text, Option(s), Number, Date, Organization, and Labels CFs
For other types of CF check documentation
issue.customFields."CF Name".value = replica.customFields."CF Name".value
*/
ADO Outgoing.groovy
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

replica.parentId = workItem.parentId

def res = httpClient.get("/_apis/wit/workitems/${workItem.key}?\$expand=relations&api-version=6.0",false)
if (res.relations != null){
    replica."relation" = res.relations[0].attributes.name
    replica."relationid" = (res.relations[0].url).tokenize('/')[7]
    }   
ADO Incoming.groovy
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.summary      = replica.summary
workItem.description  = replica.description
workItem.attachments  = attachmentHelper.mergeAttachments(workItem, replica)
workItem.comments     = commentHelper.mergeComments(workItem, replica)

/*
Area Path Sync
This also works for iterationPath field

Set Area Path Manually
workItem.areaPath = "Name of the project\\name of the area"

Set Area Path based on remote side drop-down list
Change "area-path-select-list" to actual custom field name
workItem.areaPath = replica.customFields."area-path-select-list"?.value?.value

Set Area Path based on remote side text field
Change "area-path" to actual custom field name
workItem.areaPath = replica.customFields."area-path".value
*/

/*
Status Synchronization

Sync status according to the mapping [remote workItem status: local workItem status]
If statuses are the same on both sides don"t include them in the mapping
def statusMapping = ["Open":"New", "To Do":"Open"]
def remoteStatusName = replica.status.name
workItem.setStatus(statusMapping[remoteStatusName] ?: remoteStatusName)
*/