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

Introduction


I'm excited to present this tutorial, where we will explore the seamless synchronization of Tempo Worklogs between two Jira Cloud instances using Exalate. 


To set the stage, let's consider a scenario where we have Jira Cloud instance A (JCA) and Jira Cloud instance B (JCB). When a ticket is exalated from JCA to JCB, it is seamlessly populated within JCB, allowing users to interact with it. However, a critical aspect of worklog synchronization arises when a user adds time to the ticket in JCB. It is imperative that this time entry is reflected in JCA to maintain consistency and facilitate effective project tracking.


Conversely, if a user adds or modifies a worklog in JCA, it should be automatically synchronized with JCB to ensure that all stakeholders have up-to-date information across both instances.


This bidirectional synchronization enables teams working on different Jira Cloud instances to collaborate seamlessly, regardless of their physical location or the specific instance they are using.

By implementing Exalate as the integration solution, organizations can establish a robust and reliable connection between JCA and JCB, facilitating real-time updates of Tempo Worklogs.

Tempo Worklogs


After installing Tempo Worklogs, you will see it getting added under your apps selection. 

Exalate uses Tempo Cloud REST API to get access to the tempo worklogs.


The use of the Tempo Cloud REST API enables Exalate to retrieve worklogs from the source instance and transfer them to the target instance seamlessly. It also allows for the synchronization of worklog updates, ensuring that any modifications made in one instance are accurately reflected in the other.


Generate Access Token


Exalate requires access to Tempo. To grant secure, temporary access to Tempo you need to create a user access token. This access token is based on current permissions of the Jira Cloud user.

Required permissions

The user who generated the access token must have the following permissions:

  • Jira permissions:
    • Create worklogs
    • View all worklogs
    • Log work for others
    • Work on issues
  • Tempo permissions:
    • view team worklogs
    • manage team worklogs

Generate the Access Token under Tempo settings - API integration tab in your Jira Cloud settings.




The Scripts

Jira Cloud A


Outgoing Sync Jira Cloud A


Outgoing Sync
replica.workLogs = issue.workLogs
TempoWorkLogSync.send(
	"dJWtvBhJUkoHroDcEd8iYyYfnd0Bm",  // replace the "token" with the previously generated access token
	replica, 
	issue, 
	httpClient, 
	nodeHelper
) 



Incoming Sync Jira Cloud A


Add the imports in the beginning of the code.

Add the functions at the end of the code.

Incoming Sync
import com.exalate.api.domain.webhook.WebhookEntityType
import com.exalate.basic.domain.hubobject.v1.BasicHubIssue
import com.exalate.basic.domain.hubobject.v1.BasicHubUser
import com.exalate.basic.domain.hubobject.v1.BasicHubWorkLog
import com.exalate.replication.services.replication.impersonation.AuditLogService
import org.slf4j.LoggerFactory
import org.slf4j.Logger
import play.libs.Json
import scala.concurrent.Await$;
import scala.concurrent.duration.Duration$;
import java.text.SimpleDateFormat
import java.time.Instant

//Your normal Incoming Sync code
//Add these functions at the end

Worklogs.receive(
	  "dJWtvBhJUkoHroDcEd8iYyYfnd0Bm",  // replace the "token" with the previously generated access token 
	  replica,
	  issue,
	  httpClient,
	  traces,
	  nodeHelper
)


class Worklogs {

    
 


    static receive(String token, BasicHubIssue replica, BasicHubIssue issue, httpClient, traces, nodeHelper){
        receive(
                token,
                replica,
                issue,
                httpClient,
                traces,
                nodeHelper,
                { BasicHubWorkLog w ->
                    def getUser = { String key ->
                        def localAuthor = nodeHelper.getUser(key)
                        if (localAuthor == null) {
                            localAuthor = new BasicHubUser()
                            localAuthor.key = "557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec"
                        }
                        localAuthor
                    }
                    w.author = w.author ? getUser(w.author.key) : null
                    w.updateAuthor = w.updateAuthor ? getUser(w.updateAuthor.key) : null
                    w
                }
        )
    }
   
   
    static receive(String token, BasicHubIssue replica, BasicHubIssue issue, httpClient, traces, nodeHelper, Closure<?> onWorklogFn){
        def http = { String method, String path, Map<String, List<String>> queryParams, String body, Map<String, List<String>> headers ->

            def await = { future -> Await$.MODULE$.result(future, Duration$.MODULE$.apply(60, java.util.concurrent.TimeUnit.SECONDS)) }
            def orNull = { scala.Option<?> opt -> opt.isDefined() ? opt.get() : null }
            def pair = { l, r -> scala.Tuple2$.MODULE$.<?, ?>apply(l, r) }
            def none = { scala.Option$.MODULE$.<?> empty() }

            def getGeneralSettings = {
                def classLoader = this.getClassLoader()
                def gsp
                try {
                    gsp = InjectorGetter.getInjector().instanceOf(classLoader.loadClass("com.exalate.api.persistence.issuetracker.jcloud.IJCloudGeneralSettingsRepository"))
                } catch(ClassNotFoundException exception) {
                    gsp = InjectorGetter.getInjector().instanceOf(classLoader.loadClass("com.exalate.api.persistence.issuetracker.jcloud.IJCloudGeneralSettingsPersistence"))
                }
                def gsOpt = await(gsp.get())
                def gs = orNull(gsOpt)
                gs
            }
            final def gs = getGeneralSettings()

            def removeTailingSlash = { String str -> str.trim().replace("/+\$", "") }
            final def tempoRestApiUrl = "https://api.tempo.io/core/3"

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

            
            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])
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.mailMap = [
                                recipient: schemeSpecificPartList[0],
                                cc       : tempMailMap.find { //noinspection GrUnresolvedAccess
                                    it.key.toLowerCase() == 'cc' }.value,
                                bcc      : tempMailMap.find { //noinspection GrUnresolvedAccess
                                    it.key.toLowerCase() == 'bcc' }.value,
                                subject  : tempMailMap.find { //noinspection GrUnresolvedAccess
                                    it.key.toLowerCase() == 'subject' }.value,
                                body     : tempMailMap.find { //noinspection GrUnresolvedAccess
                                    it.key.toLowerCase() == 'body' }.value
                        ]
                    }
                    if (parsedUri.fragment?.contains('?')) { 
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.rawQuery = parsedUri.rawFragment.split('\\?')[1]
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.query = parsedUri.fragment.split('\\?')[1]
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.rawFragment = parsedUri.rawFragment.split('\\?')[0]
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.fragment = parsedUri.fragment.split('\\?')[0]
                    }
                    if (parsedUri.rawQuery) {
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.queryMap = parseQueryString(parsedUri.rawQuery)
                    } else {
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.queryMap = null
                    }

                    //noinspection GrUnresolvedAccess
                    if (parsedUri.queryMap) {
                        //noinspection GrUnresolvedAccess
                        parsedUri.queryMap.keySet().each { key ->
                            def value = parsedUri.queryMap[key]
                            //noinspection GrUnresolvedAccess
                            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
            }

            def unsanitizedUrl = tempoRestApiUrl + path
            def parsedUri = parseUri(unsanitizedUrl)

       
            def embeddedQueryParams = parsedUri.queryMap

            def allQueryParams = embeddedQueryParams instanceof Map ?
                    ({
                        def m = [:] as Map<String, List<String>>;
                        m.putAll(embeddedQueryParams as Map<String, List<String>>)
                        m.putAll(queryParams)
                    })()
                    : (queryParams ?: [:] as 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, 
                        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(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
                    })
                    request = request.withQueryString(scalaQueryParams)
                }

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


            } catch (Exception e) {
                throw new com.exalate.api.exception.IssueTrackerException("Unable to perform the request $method $path, please contact Exalate Support: ".toString() + e.message, e)
            }
            if (response.status() >= 300) {
                throw new com.exalate.api.exception.IssueTrackerException("Failed to perform the request $method $path ${body ? "with body `$body`".toString() : ""}(status ${response.status()}), please contact Exalate Support: ".toString() + response.body())
            }
            response.body() as String
        }

        def gsp = InjectorGetter.getInjector().instanceOf(AuditLogService.class)

        def js = new groovy.json.JsonSlurper()
        def jo = new groovy.json.JsonOutput()

        def listAdditionalParams = replica.customKeys."tempoWorklogParams" as Map<String, Map<String, Object>>;
        
        replica.workLogs.findAll{it.id == null}.each{ BasicHubWorkLog worklog ->
            def transformedWorklog
            
          
            try {
                transformedWorklog = onWorklogFn(worklog)
            } catch (com.exalate.api.exception.IssueTrackerException ite) {
                throw ite
            } catch (Exception e) {
                throw new com.exalate.api.exception.IssueTrackerException(e)
            }
            if (transformedWorklog instanceof BasicHubWorkLog) {
                worklog = transformedWorklog as BasicHubWorkLog
            } else if (transformedWorklog == null) {
                return
            }

            def auditLogOpt = gsp.createAuditLog(scala.Option$.MODULE$.<String>apply(issue.id as String),
                    WebhookEntityType.WORKLOG_CREATED,
                    worklog.getAuthor().getKey()
            )

            def attributes = ((listAdditionalParams?.get(worklog.remoteId.toString())?.get("attributes") as Map<String, Object>)?.get("values") as List<Map<String, String>>)?.inject([]){
                List<Map<String, String>>result, Map<String, String> attribute ->
                    result.add(
                            [
                                    key: attribute.get("key"),
                                    value: attribute.get("value")
                            ]
                    )
                    result
            } ?: []
            def properties = [
                    issueKey : issue.key,
                    timeSpentSeconds : worklog.getTimeSpent(),
                    billableSeconds: listAdditionalParams?.get(worklog.remoteId.toString())?.get("billableSeconds") ?: worklog.getTimeSpent(),
                    startDate : new java.text.SimpleDateFormat("yyyy-MM-dd").format(worklog.startDate),
                    startTime : new java.text.SimpleDateFormat("hh:mm:ss").format(worklog.startDate) ?: listAdditionalParams?.get(worklog.remoteId.toString())?.get("startTime"),//strDateSplitted[1].split("\\.")[0],
                    description : worklog.getComment(),
                    authorAccountId : worklog.getAuthor().getKey(),
                    remainingEstimateSeconds:  replica.remainingEstimate ?: listAdditionalParams?.get(worklog.remoteId.toString())?.get("remainingEstimateSeconds"),
                    attributes : attributes
            ]
            def jsonTempoPost = jo.toJson(properties)


            def response = js.parseText(http(
                    "POST",
                    "/worklogs",
                    null,
                    jsonTempoPost,
                    [
                            "Authorization":["Bearer ${token}".toString()],
                            "Content-Type":["application/json"],
                    ]
            ))
            println(response)

            gsp.updateAuditLog(scala.Option$.MODULE$.apply(auditLogOpt), issue.id as String, response["jiraWorklogId"] as String, Json.stringify(Json.toJson(response)))


            String localIdStr = response["tempoWorklogId"]
            String remoteIdStr = worklog.remoteId.toString()

            com.exalate.api.domain.twintrace.INonPersistentTrace trace = new com.exalate.basic.domain.BasicNonPersistentTrace()
                    .setLocalId(localIdStr)
                    .setRemoteId(remoteIdStr)
                    .setType(com.exalate.api.domain.twintrace.TraceType.WORKLOG)
                    .setAction(com.exalate.api.domain.twintrace.TraceAction.NONE)
                    .setToSynchronize(true)
            traces.add(trace)
        }



    }

    
}



Jira Cloud B


Outgoing Sync Jira Cloud B



Outgoing Sync
replica.workLogs = issue.workLogs
TempoWorkLogSync.send(
	"B0V4tQJ22LhPnznllWoT2s0N29So5",  // replace the "token" with the previously generated access token
	replica, 
	issue, 
	httpClient, 
	nodeHelper
)  



Incoming Sync Jira Cloud B


Add the imports in the beginning of the code.

Add the functions at the end of the code.

Incoming Sync
import com.exalate.api.domain.webhook.WebhookEntityType
import com.exalate.basic.domain.hubobject.v1.BasicHubIssue
import com.exalate.basic.domain.hubobject.v1.BasicHubUser
import com.exalate.basic.domain.hubobject.v1.BasicHubWorkLog
import com.exalate.replication.services.replication.impersonation.AuditLogService
import org.slf4j.LoggerFactory
import org.slf4j.Logger
import play.libs.Json
import scala.concurrent.Await$;
import scala.concurrent.duration.Duration$;
import java.text.SimpleDateFormat
import java.time.Instant

//Your normal Incoming Sync code
//Add these functions at the end

Worklogs.receive(
	  "B0V4tQJ22LhPnznllWoT2s0N29So5",  // replace the "token" with the previously generated access token  
	  replica,
	  issue,
	  httpClient,
	  traces,
	  nodeHelper
)


class Worklogs {

    
 


    static receive(String token, BasicHubIssue replica, BasicHubIssue issue, httpClient, traces, nodeHelper){
        receive(
                token,
                replica,
                issue,
                httpClient,
                traces,
                nodeHelper,
                { BasicHubWorkLog w ->
                    def getUser = { String key ->
                        def localAuthor = nodeHelper.getUser(key)
                        if (localAuthor == null) {
                            localAuthor = new BasicHubUser()
                            localAuthor.key = "557058:c020323a-70e4-4c07-9ccc-3ad89b1c02ec"
                        }
                        localAuthor
                    }
                    w.author = w.author ? getUser(w.author.key) : null
                    w.updateAuthor = w.updateAuthor ? getUser(w.updateAuthor.key) : null
                    w
                }
        )
    }
   
   
    static receive(String token, BasicHubIssue replica, BasicHubIssue issue, httpClient, traces, nodeHelper, Closure<?> onWorklogFn){
        def http = { String method, String path, Map<String, List<String>> queryParams, String body, Map<String, List<String>> headers ->

            def await = { future -> Await$.MODULE$.result(future, Duration$.MODULE$.apply(60, java.util.concurrent.TimeUnit.SECONDS)) }
            def orNull = { scala.Option<?> opt -> opt.isDefined() ? opt.get() : null }
            def pair = { l, r -> scala.Tuple2$.MODULE$.<?, ?>apply(l, r) }
            def none = { scala.Option$.MODULE$.<?> empty() }

            def getGeneralSettings = {
                def classLoader = this.getClassLoader()
                def gsp
                try {
                    gsp = InjectorGetter.getInjector().instanceOf(classLoader.loadClass("com.exalate.api.persistence.issuetracker.jcloud.IJCloudGeneralSettingsRepository"))
                } catch(ClassNotFoundException exception) {
                    gsp = InjectorGetter.getInjector().instanceOf(classLoader.loadClass("com.exalate.api.persistence.issuetracker.jcloud.IJCloudGeneralSettingsPersistence"))
                }
                def gsOpt = await(gsp.get())
                def gs = orNull(gsOpt)
                gs
            }
            final def gs = getGeneralSettings()

            def removeTailingSlash = { String str -> str.trim().replace("/+\$", "") }
            final def tempoRestApiUrl = "https://api.tempo.io/core/3"

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

            
            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])
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.mailMap = [
                                recipient: schemeSpecificPartList[0],
                                cc       : tempMailMap.find { //noinspection GrUnresolvedAccess
                                    it.key.toLowerCase() == 'cc' }.value,
                                bcc      : tempMailMap.find { //noinspection GrUnresolvedAccess
                                    it.key.toLowerCase() == 'bcc' }.value,
                                subject  : tempMailMap.find { //noinspection GrUnresolvedAccess
                                    it.key.toLowerCase() == 'subject' }.value,
                                body     : tempMailMap.find { //noinspection GrUnresolvedAccess
                                    it.key.toLowerCase() == 'body' }.value
                        ]
                    }
                    if (parsedUri.fragment?.contains('?')) { 
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.rawQuery = parsedUri.rawFragment.split('\\?')[1]
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.query = parsedUri.fragment.split('\\?')[1]
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.rawFragment = parsedUri.rawFragment.split('\\?')[0]
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.fragment = parsedUri.fragment.split('\\?')[0]
                    }
                    if (parsedUri.rawQuery) {
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.queryMap = parseQueryString(parsedUri.rawQuery)
                    } else {
                        //noinspection GrUnresolvedAccess
                        parsedUri.metaClass.queryMap = null
                    }

                    //noinspection GrUnresolvedAccess
                    if (parsedUri.queryMap) {
                        //noinspection GrUnresolvedAccess
                        parsedUri.queryMap.keySet().each { key ->
                            def value = parsedUri.queryMap[key]
                            //noinspection GrUnresolvedAccess
                            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
            }

            def unsanitizedUrl = tempoRestApiUrl + path
            def parsedUri = parseUri(unsanitizedUrl)

       
            def embeddedQueryParams = parsedUri.queryMap

            def allQueryParams = embeddedQueryParams instanceof Map ?
                    ({
                        def m = [:] as Map<String, List<String>>;
                        m.putAll(embeddedQueryParams as Map<String, List<String>>)
                        m.putAll(queryParams)
                    })()
                    : (queryParams ?: [:] as 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, 
                        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(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
                    })
                    request = request.withQueryString(scalaQueryParams)
                }

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


            } catch (Exception e) {
                throw new com.exalate.api.exception.IssueTrackerException("Unable to perform the request $method $path, please contact Exalate Support: ".toString() + e.message, e)
            }
            if (response.status() >= 300) {
                throw new com.exalate.api.exception.IssueTrackerException("Failed to perform the request $method $path ${body ? "with body `$body`".toString() : ""}(status ${response.status()}), please contact Exalate Support: ".toString() + response.body())
            }
            response.body() as String
        }

        def gsp = InjectorGetter.getInjector().instanceOf(AuditLogService.class)

        def js = new groovy.json.JsonSlurper()
        def jo = new groovy.json.JsonOutput()

        def listAdditionalParams = replica.customKeys."tempoWorklogParams" as Map<String, Map<String, Object>>;
        
        replica.workLogs.findAll{it.id == null}.each{ BasicHubWorkLog worklog ->
            def transformedWorklog
            
          
            try {
                transformedWorklog = onWorklogFn(worklog)
            } catch (com.exalate.api.exception.IssueTrackerException ite) {
                throw ite
            } catch (Exception e) {
                throw new com.exalate.api.exception.IssueTrackerException(e)
            }
            if (transformedWorklog instanceof BasicHubWorkLog) {
                worklog = transformedWorklog as BasicHubWorkLog
            } else if (transformedWorklog == null) {
                return
            }

            def auditLogOpt = gsp.createAuditLog(scala.Option$.MODULE$.<String>apply(issue.id as String),
                    WebhookEntityType.WORKLOG_CREATED,
                    worklog.getAuthor().getKey()
            )

            def attributes = ((listAdditionalParams?.get(worklog.remoteId.toString())?.get("attributes") as Map<String, Object>)?.get("values") as List<Map<String, String>>)?.inject([]){
                List<Map<String, String>>result, Map<String, String> attribute ->
                    result.add(
                            [
                                    key: attribute.get("key"),
                                    value: attribute.get("value")
                            ]
                    )
                    result
            } ?: []
            def properties = [
                    issueKey : issue.key,
                    timeSpentSeconds : worklog.getTimeSpent(),
                    billableSeconds: listAdditionalParams?.get(worklog.remoteId.toString())?.get("billableSeconds") ?: worklog.getTimeSpent(),
                    startDate : new java.text.SimpleDateFormat("yyyy-MM-dd").format(worklog.startDate),
                    startTime : new java.text.SimpleDateFormat("hh:mm:ss").format(worklog.startDate) ?: listAdditionalParams?.get(worklog.remoteId.toString())?.get("startTime"),//strDateSplitted[1].split("\\.")[0],
                    description : worklog.getComment(),
                    authorAccountId : worklog.getAuthor().getKey(),
                    remainingEstimateSeconds:  replica.remainingEstimate ?: listAdditionalParams?.get(worklog.remoteId.toString())?.get("remainingEstimateSeconds"),
                    attributes : attributes
            ]
            def jsonTempoPost = jo.toJson(properties)


            def response = js.parseText(http(
                    "POST",
                    "/worklogs",
                    null,
                    jsonTempoPost,
                    [
                            "Authorization":["Bearer ${token}".toString()],
                            "Content-Type":["application/json"],
                    ]
            ))
            println(response)

            gsp.updateAuditLog(scala.Option$.MODULE$.apply(auditLogOpt), issue.id as String, response["jiraWorklogId"] as String, Json.stringify(Json.toJson(response)))


            String localIdStr = response["tempoWorklogId"]
            String remoteIdStr = worklog.remoteId.toString()

            com.exalate.api.domain.twintrace.INonPersistentTrace trace = new com.exalate.basic.domain.BasicNonPersistentTrace()
                    .setLocalId(localIdStr)
                    .setRemoteId(remoteIdStr)
                    .setType(com.exalate.api.domain.twintrace.TraceType.WORKLOG)
                    .setAction(com.exalate.api.domain.twintrace.TraceAction.NONE)
                    .setToSynchronize(true)
            traces.add(trace)
        }



    }

  }



Video






Questions