Can I set Jira comment properties?

Originally asked by Charlie Misonne on 17 October 2022 (original question)


Jira Cloud has a concept called comment properties which can be used via the REST API and automation for Jira.

Can I set comment properties via Exalate scripts? If I can’t, can I create a feature request somewhere?

My use case: I am syncing comments from Azure Devops to Jira sub-tasks.

Once in Jira there is an automation rule copying the comments to the parent issue. But we also have a rule copying the parent comments to the sub-task issues.

Of course I want to avoid duplicate comments. A robust solution would be to add a comment property to keep whether this was a comment originally created by Exalate on a sub-task. Those comments don’t have to be syncec back to the sub-task.


Comments:

Serhiy Onyshchenko commented on 17 October 2022

Hello, Charlie Misonne thanks for raising this on community!
Aren’t all comments by default created via Exalate User?
If so, you may identify comments created by Exalate via author.

worst-case-scenario, it’s possible to create comments via Jira’s REST API, using JiraClient
Like so:

//issue.comments = commentHelper.mergeComments(issue, replica)if (firstSync) { store(issue) }
def jc = new JiraClient(httpClient)
def js = new groovy.json.JsonSlurper()
replica.addedComments.each { remoteComment ->
  def bodyStr = groovy.json.JsonOutput.toJson(
    [body:remoteComment.body, properties:[[key:"createdByExalate", "value":true]]]
  )
  def result = jc.http("POST", "/rest/api/2/issue/${issue.idStr}/comment", [:], bodyStr, ["Content-Type":"application/json"]) { response -> 
    if(response.code >= 300) debug.error("Failed to create comment with properties: ${response.body}")
    else {
      def json = js.parseText(response.body)
      json
    }
  }
  def localCommentId = result.id  def trace = new com.exalate.basic.domain.BasicNonPersistentTrace()
     .setType(com.exalate.api.domain.twintrace.TraceType.COMMENT)
     .setToSynchronize(true)
     .setLocalId(localCommentId)
     .setRemoteId(remoteComment.remoteId as String)
     .setAction(com.exalate.api.domain.twintrace.TraceAction.NONE)
  traces.add(trace)

}

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

Answer by Serhiy Onyshchenko on 19 October 2022

Here’s how one can create comments with properties:

Here’s a modified / validated version of the incoming script:

if(firstSync){
    issue.projectKey   = "SD"
    issue.typeName     = nodeHelper.getIssueType(replica.type?.name, issue.projectKey)?.name ?: "Task"
}

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


//issue.comments = commentHelper.mergeComments(issue, replica)if (firstSync) { store(issue) }
def js = new groovy.json.JsonSlurper()
store(issue)
def propertyMap = [
        "key": "createdByExalate",
        "value": [ "v" : "true" ]
]
def propertylist=[propertyMap]
replica.addedComments.each { remoteComment ->
    def bodyStr = groovy.json.JsonOutput.toJson(
            [body: remoteComment.body, properties: propertylist]
    )
    //debug.error(""+bodyStr+"--"+issue.key)
    def res = new JiraClient(httpClient, injector, debug)
            .http(
                    "POST",
                    "/rest/api/2/issue/${issue.key}/comment",
                    [:],
                    bodyStr,
                    ["Content-Type": ["application/json"]]) { response ->
                if (response.code >= 400) debug.error("POST /rest/api/2/issue/${issue.key}/comment failed: ${response.body}")
                else {
                    //store(issue)
                    //debug.error(remoteComment.body+"---"+issue.comments.last())
                    //debug.error(""+replica.addedComments)
                    def json = js.parseText(response.body)
                    def localCommentId = json.id
                    def trace = new com.exalate.basic.domain.BasicNonPersistentTrace()
                            .setType(com.exalate.api.domain.twintrace.TraceType.COMMENT)
                            .setToSynchronize(true)
                            .setLocalId(localCommentId as String)
                            .setRemoteId(remoteComment.remoteId as String)
                            .setAction(com.exalate.api.domain.twintrace.TraceAction.NONE)
                    traces.add(trace)
                }
            }
}

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

class JiraClient {
    // SCALA HELPERS
    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) }

    // SERVICES AND EXALATE API
    private def getGeneralSettings() {
        def gsp = injector.instanceOf(com.exalate.api.persistence.issuetracker.jcloud.IJCloudGeneralSettingsPersistence.class)
        def gsOpt = await(gsp.get())
        def gs = orNull(gsOpt)
        gs
    }
    private String getJiraCloudUrl() {
        final def gs = getGeneralSettings()

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

    private httpClient
    private injector
    private debug

    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\n$e", e)
            }
            parsedUri
        }
    }

    JiraClient(httpClient, injector, debug) {
        this.httpClient = httpClient
        this.injector = injector
        this.debug = debug
    }

    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: \n```$body```\nPlease 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 = jiraCloudUrl + 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
                    .ws()
                    .url(sanitizedUrl)
                    .withMethod(method)

            if (headers != null && !headers.isEmpty()) {
                def scalaHeaders = scala.collection.JavaConversions.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
                        }
                )
                request = request.withHeaders(scalaHeaders)
            }

            if (!allQueryParams.isEmpty()) {
                def scalaQueryParams = scala.collection.JavaConversions.asScalaBuffer(allQueryParams.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
                })
                request = request.withQueryString(scalaQueryParams)
            }

            if (body != null) {
                def writable = play.api.libs.ws.WSBodyWritables$.MODULE$.writeableOf_String()
                request = request.withBody(body, writable)
            }

            request = await(httpClient.authenticate(
                    none(),
                    request,
                    gs
            ))
            def authEntry = scala.collection.JavaConverters
              .mapAsJavaMap(request.headers())
              .entrySet()
              .find { kv -> "authorization".equalsIgnoreCase(kv.key) }
            //debug.error("curl -X ${request.method()} -H '${authEntry.key}: ${authEntry.value.head}' -d '${body}' '${request.url()}${({def qpStr = allQueryParams.inject(""){res, kvs -> res + kvs.collect { k, vs -> vs.collect { v -> "$k=$v" }.join("&"); (qpStr.isEmpty())? "" : "?$qpStr" }}})()}'")
            response = await(request.execute())
        } catch (Exception e) {
            throw new com.exalate.api.exception.IssueTrackerException("Unable to perform the request $method $path with body: \n```$body```\n, 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
        }
    }
}

Regards, Serhiy.


Comments:

Charlie Misonne commented on 20 October 2022

Hi Serhiy,

Thanks for your answer and that script.

Creating the comments via the REST API is too complex for what I need.

Like you suggested I am now filtrering on the comment author in my automation rules instead of using the customer property.

Still, in the future it might be nice if Exalate could support comment properties without using the REST API.

kind regards,

Charlie

This topic was automatically closed 2 days after the last reply. New replies are no longer allowed.