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:
//replica.comments = issue.comments replica.comments = nodeHelper.getHtmlComments(issue)
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
replica.comments = issue.comments.findAll { comment -> comment.author.displayName != "Proxy User Name" }
Questions
Recent Questions
- 0votes
Mention FullName from Jira On-Premise to Jira Cloud comment sync
- 0 answers
- Anthony Alberto
- Jun 03, 2024
- Space: Exalate
- 0votes
- 0votes
- 0votes
Issues are not syncing to local destination desk
- 1 answer
- John Lombardo
- Mar 21, 2024
- Space: Exalate
- 0votes
- 0votes
- 0votes
- 0votes
How to have keys match between jira cloud instances
- 2 answers
- Chris Matthews
- Feb 07, 2024
- Space: Exalate
- 0votes
how decrease dimension of /var/atlassian/application-data/jira/data/exalate
- 1 answer
- Francesco Doricchi
- Dec 14, 2023
- Space: Exalate
- 1vote
How to Impersonate Attachments in Jira Cloud?
- 0 answers
- Valeriia Solianikova
- Dec 12, 2023
- Space: Exalate