Note

Currently, you cannot sign-up to the community. We're fixing the issue. Sorry for the inconvenience. Please use AIDA (check below) for any queries.

The Exalate team will be on holiday for the coming days - returning Jan 4
Enjoy & stay safe

Introduction

Jira Cloud comment supports formatting like bold, italic, underline , colour fonts and many more. To sync this to Zendesk is not totally supported by Exalate. The reason being, Jira cloud is using Mark Up language where Zendesk is using Markdown and HTML both. Exalate has default methods and scripts to convert into Markdown but that has some limitation like it will not support colour fonts , images and few more.


To achieve this , here is the advance script which helps you to create comment with html content.


The code:

Jira Cloud Outgoing Sync Script
//replica.comments = issue.comments
replica.comments = nodeHelper.getHtmlComments(issue)
Zendesk Incoming Sync Script
issue.labels = replica.labels
issue.summary = replica.summary
issue.description=replica.description ?: "No description"
issue.attachments = attachmentHelper.mergeAttachments(issue, replica)
// issue.comments = nodeHelper.toMarkDownComments(includeComments)

// advance script starts from here 
 store(issue)
def zdClient = new ZdClient(httpClient, log,debug,nodeHelper)
log.error(""+replica.addedComments+" \n traces:"+traces)
 def jsonSlurper = new groovy.json.JsonSlurper()
  def attachmentsMap = [:]      
 zdClient
          .http(
            "GET",
            "/api/v2/tickets/${issue.key}/comments",
            [:],
            null,
            ["Accept": ["application/json"], "Content-type": ["application/json"]]
          ) {
            response2 ->
              if (response2.code >= 400) {

                throw new com.exalate.api.exception.IssueTrackerException("Failed to get comments with status code  " + response.code + " and with details " + response.body)
              }
            else {
              def commentResponse = jsonSlurper.parseText(response2.body)

              // Assuming you want to find the latest comment by the highest ID
              if(commentResponse.comments.size()>0)
              {
                commentResponse.comments.each { comment ->
    				if (comment.attachments) 
					{ // Check if attachments exist
        				comment.attachments.each 
						{ 
							attachment ->
            				attachmentsMap[attachment.file_name] = attachment.content_url
        				}
    				}
				}
              }
            }
          }


replica.addedComments.each {
  it ->
    def commentBody = it.body
   
def json = new groovy.json.JsonBuilder()
commentBody = commentBody.replaceAll("<ins>", "<u>").replaceAll("</ins>", "</u>")
String updatedHtmlContent = commentBody.replaceAll(/<img src="\/attachments\/(\d+)\?name=([^"]+)" alt="([^"]+)">/) { fullMatch, id, imageName, altText ->
    String newToken = attachmentsMap[imageName]
    if (newToken) {
        return """<img src="${newToken}" alt="${altText}" />"""
    } else {
        return fullMatch // return original if no mapping found
    }
}


json.ticket {
    comment {
        html_body updatedHtmlContent
    }
}

def jsonString = json.toString()
  zdClient.http("PUT","/api/v2/tickets/${issue.key}.json",[:],jsonString,["Accept": ["application/json"], "Content-type": ["application/json"]]) 
  {
      response ->
        if (response.code >= 400) {

          throw new com.exalate.api.exception.IssueTrackerException("Failed to create html comment with status code  " + response.code + " and with details " + response.body)
        }
      else {
        zdClient
          .http(
            "GET",
            "/api/v2/tickets/${issue.key}/comments",
            [:],
            null,
            ["Accept": ["application/json"], "Content-type": ["application/json"]]
          ) {
            response2 ->
              if (response2.code >= 400) {

                throw new com.exalate.api.exception.IssueTrackerException("Failed to get comments with status code  " + response.code + " and with details " + response.body)
              }
            else {
              def commentResponse = jsonSlurper.parseText(response2.body)

              if(commentResponse.comments.size()>0)
              {
              def latestComment = commentResponse.comments[commentResponse.comments.size()-1] 
              def commentId=latestComment.id?:null
              if (commentId) {
                def trace = new com.exalate.basic.domain.BasicNonPersistentTrace()
                  .setType(com.exalate.api.domain.twintrace.TraceType.COMMENT)
                  .setToSynchronize(true)
                  .setLocalId(commentId as String)
                  .setRemoteId(it.remoteId as String)
                  .setAction(com.exalate.api.domain.twintrace.TraceAction.NONE)
                traces.add(trace)
                 log.error(""+trace+" \n all:"+traces)
              }
              }
            }
          }

      }
    }
 }
return new scala.Tuple2(issueKey, scala.collection.JavaConverters.asScalaBuffer(traces))


/*
  Custom Fields (CF)
     To add incoming values to a Zendesk custom field, follow these steps:
     1/ Find the Display Name of the CF.
     2/ Check how the value is coming over from the source side, by checking the "Entity Sync Status" of a ticket in sync and then selecting "Show Remote Replica".
     3/ Add it all together like this:
     issue.customFields."CF Name".value = replica.customFields."CF Name".value
*/

/*
  Status Syncronization
      For Status Syncing, we map the source status, to the destination status with a hash map.
      The syntax is as follows: def statusMap = ["remote status name": "local status name"]
      Go to Entity Sync Status, put in the ticket key, and it will show you where to find the remote replica by clicking on Show remote replica.
      Note that values in Zendesk (on the right side) are in lower case
      def statusMap = [
          "New"   : "open"
          "Done"  : "solved"
        ]
      def remoteStatusName = replica.status.name
      issue.setStatus(statusMapping[remoteStatusName] ?: remoteStatusName)
*/

//Exalate API Reference Documentation: https://docs.exalate.com/docs/exalate-api-reference-documentation

class InjectorGetter {

  static Object getInjector() {
    try {
      return play.api.Play$.MODULE$.current().injector()
    } catch (e) {
      def context = com.exalate.replication.services.processor.CreateReplicaProcessor$.MODULE$.threadLocalContext.get()
      if (!context) {
        context = com.exalate.replication.services.processor.ChangeIssueProcessor$.MODULE$.threadLocalContext.get()
      }
      if (!context) {
        context = com.exalate.replication.services.processor.CreateIssueProcessor$.MODULE$.threadLocalContext.get()
      }
      if (!context) {
        throw new com.exalate.api.exception.IssueTrackerException(""" No context for executing external script CreateIssue.groovy. Please contact Exalate Support.""".toString())
      }
      context.injector
    }
  }
}

class ZdClient {
    // SCALA HELPERS
    private def gsp

    private static <T> T await(scala.concurrent.Future<T> f) {
        scala.concurrent.Await$.MODULE$.result(f, scala.concurrent.duration.Duration$.MODULE$.Inf())
    }

    private static <T> T orNull(scala.Option<T> opt) { opt.isDefined() ? opt.get() : null }

    private static <T> scala.Option<T> none() { scala.Option$.MODULE$.<T> empty() }

    @SuppressWarnings("GroovyUnusedDeclaration")
    private static <T> scala.Option<T> none(Class<T> evidence) { scala.Option$.MODULE$.<T> empty() }

    private static <L, R> scala.Tuple2<L, R> pair(L l, R r) { scala.Tuple2$.MODULE$.<L, R> apply(l, r) }

    private def getContex(){
        if(!this.gsp){
            this.gsp = InjectorGetter.getInjector().instanceOf(com.exalate.api.persistence.issuetracker.IGeneralSettingsRepository.class)
        }
        return this.gsp
    }

    private def getGeneralSettings() {
        def await = { f -> scala.concurrent.Await$.MODULE$.result(f, scala.concurrent.duration.Duration$.MODULE$.Inf()) }
nodeHelper.zendeskClient.generalSettingsService
def orNull = { scala.Option<?> opt -> opt.isDefined() ? opt.get() : null }
def ec = nodeHelper.zendeskClient.ec
def gsOptFuture = nodeHelper.zendeskClient.generalSettingsService.get()
def gs = orNull(await(gsOptFuture))
gs
    }

    private  String getIssueTrackerUrl() {
        final def gs = getGeneralSettings()

        def removeTailingSlash = { String str -> str.trim().replace("/+\$", "") }
        final def issueTrackerUrl = removeTailingSlash(gs.issueTrackerUrl)
        issueTrackerUrl
    }

    private httpClient
    private log
    private debug
    private nodeHelper
    def parseQueryString = { String string ->
        string.split('&').collectEntries { param ->
            param.split('=', 2).collect { URLDecoder.decode(it, 'UTF-8') }
        }
    }

    //Usage examples: https://gist.github.com/treyturner/4c0f609677cbab7cef9f
    def parseUri
    {
        parseUri = { String uri ->
            def parsedUri
            try {
                parsedUri = new URI(uri)
                if (parsedUri.scheme == 'mailto') {
                    def schemeSpecificPartList = parsedUri.schemeSpecificPart.split('\\?', 2)
                    def tempMailMap = parseQueryString(schemeSpecificPartList[1])
                    parsedUri.metaClass.mailMap = [
                            recipient: schemeSpecificPartList[0],
                            cc       : tempMailMap.find { it.key.toLowerCase() == 'cc' }.value,
                            bcc      : tempMailMap.find { it.key.toLowerCase() == 'bcc' }.value,
                            subject  : tempMailMap.find { it.key.toLowerCase() == 'subject' }.value,
                            body     : tempMailMap.find { it.key.toLowerCase() == 'body' }.value
                    ]
                }
                if (parsedUri.fragment?.contains('?')) { // handle both fragment and query string
                    parsedUri.metaClass.rawQuery = parsedUri.rawFragment.split('\\?')[1]
                    parsedUri.metaClass.query = parsedUri.fragment.split('\\?')[1]
                    parsedUri.metaClass.rawFragment = parsedUri.rawFragment.split('\\?')[0]
                    parsedUri.metaClass.fragment = parsedUri.fragment.split('\\?')[0]
                }
                if (parsedUri.rawQuery) {
                    parsedUri.metaClass.queryMap = parseQueryString(parsedUri.rawQuery)
                } else {
                    parsedUri.metaClass.queryMap = null
                }

                if (parsedUri.queryMap) {
                    parsedUri.queryMap.keySet().each { key ->
                        def value = parsedUri.queryMap[key]
                        if (value.startsWith('http') || value.startsWith('/')) {
                            parsedUri.queryMap[key] = parseUri(value)
                        }
                    }
                }
            } catch (e) {
                throw new com.exalate.api.exception.IssueTrackerException("Parsing of URI failed: $uri $e ", e)
            }
            parsedUri
        }
    }

    ZdClient(httpClient,log,debug,nodeHelper) {
        this.httpClient = httpClient
        this.log=log
        this.debug=debug
        this.nodeHelper=nodeHelper
    }

    String http(String method, String path, java.util.Map<String, List<String>> queryParams, String body, java.util.Map<String, List<String>> headers) {
        http(method, path, queryParams, body, headers) { Response response ->
            if (response.code >= 300) {
                throw new com.exalate.api.exception.IssueTrackerException(
                        """Failed to perform the request $method $path (status ${response.code}), 
    and body was: ```$body``` 
    Please contact Exalate Support: """.toString() + response.body
                )
            }
            response.body as String
        }
    }

    public <R> R http(String method, String path, java.util.Map<String, List<String>> queryParams, String body, java.util.Map<String, List<String>> headers, Closure<R> transformResponseFn) {

        def  gs= getGeneralSettings()
        def unsanitizedUrl = issueTrackerUrl + path
        def parsedUri = parseUri(unsanitizedUrl)

        def embeddedQueryParams = parsedUri.queryMap

        def allQueryParams = embeddedQueryParams instanceof java.util.Map ?
                ({
                    def m = [:] as java.util.Map<String, List<String>>;
                    m.putAll(embeddedQueryParams as java.util.Map<String, List<String>>)
                    m.putAll(queryParams)
                })()
                : (queryParams ?: [:] as java.util.Map<String, List<String>>)

        def urlWithoutQueryParams = { String url ->
            URI uri = new URI(url)
            new URI(uri.getScheme(),
                    uri.getUserInfo(), uri.getHost(), uri.getPort(),
                    uri.getPath(),
                    null, // Ignore the query part of the input url
                    uri.getFragment()).toString()
        }
        def sanitizedUrl = urlWithoutQueryParams(unsanitizedUrl)

        def response
        try {
            def request = httpClient
                    .zendeskClient
                    .ws
                    .url(sanitizedUrl)
                    .withMethod(method)
            if (!allQueryParams.isEmpty()) {
                def scalaQueryParams = scala.collection.JavaConverters.asScalaBuffer(
                        queryParams
                                .entrySet()
                                .inject([] as List<scala.Tuple2<String, String>>) { List<scala.Tuple2<String, String>> result, kv ->
                                    kv.value.each { v -> result.add(pair(kv.key, v) as scala.Tuple2<String, String>) }
                                    result
                                }
                ).toSeq()
                request = request.withQueryString(scalaQueryParams)
            }


            if (headers != null && !headers.isEmpty()) {
                def scalaHeaders = scala.collection.JavaConverters.asScalaBuffer(
                        headers
                                .entrySet()
                                .inject([] as List<scala.Tuple2<String, String>>) { List<scala.Tuple2<String, String>> result, kv ->
                                    kv.value.each { v -> result.add(pair(kv.key, v) as scala.Tuple2<String, String>) }
                                    result
                                }
                ).toSeq()
                request = request.withHeaders(scalaHeaders)
            }
            if (body != null) {
                def writable = play.api.libs.ws.WSBodyWritables$.MODULE$.writeableOf_String()
                request = request.withBody(body, writable)
              //  debug.error("re:"+request+" and body:"+body)
            }
            def authorizationHeader = await(httpClient.zendeskClient.getAuthHeaderFromGs())
            request = request.addHttpHeaders(scala.collection.JavaConverters.asScalaBuffer([pair("Authorization", authorizationHeader) as scala.Tuple2<String, String>]).toSeq())
            log.error("body:"+body+" and request:"+request)
            response = await(request.execute())
        } catch (Exception e) {
            throw new com.exalate.api.exception.IssueTrackerException(
                    """Unable to perform the request $method $path with body:```$body```, 
    please contact Exalate Support: """.toString() + e.message,
                    e
            )
        }
        java.util.Map<String, List<String>> javaMap = [:]
        for (scala.Tuple2<String, scala.collection.Seq<String>> headerTuple : scala.collection.JavaConverters.bufferAsJavaListConverter(response.allHeaders().toBuffer()).asJava()) {
            def javaList = []
            javaList.addAll(scala.collection.JavaConverters.bufferAsJavaListConverter(headerTuple._2().toBuffer()).asJava())
            javaMap[headerTuple._1()] = javaList
        }
        def javaResponse = new Response(response.body(), new Integer(response.status()), javaMap)
        return transformResponseFn(javaResponse)
    }

    public static class Response {
        final String body
        final Integer code
        final java.util.Map<String, List<String>> headers

        Response(String body, Integer code, java.util.Map<String, List<String>> headers) {
            this.body = body
            this.code = code
            this.headers = headers
        }
    }
}

Now, because we are adding traces manually , as soon as Exalate adds the traces , Zendesk fetches that last synced comment and sync back to Jira cloud  , to avoid this we will filter the author in outgoing sync script.

Note: This will not work in case we impersonate the Zendesk comment 

Zendesk Outgoing Sync Script
replica.comments = issue.comments.findAll { comment -> comment.author.displayName != "Proxy User Name" }


Questions