How to Sync Tempo Worklogs Between Two Jira Cloud Instances

Sync Tempo Worklogs

This article was originally published on the Atlassian Community.

In today’s interconnected business environments, teams are distributed across different departments or organizations. Hence, maintaining accurate and synchronized work logs becomes essential to ensure transparency, optimize resource allocation, and enable effective decision-making. 

This use case explores the process of syncing Tempo Worklogs between two Jira Cloud instances. By implementing this synchronization, teams can bridge the gap between disparate Jira instances, harmonize work data, and unlock the full potential of their collaborative efforts.

Jump to:

The Use Case

To set the context, let’s envision a situation involving two Jira Cloud instances: Instance A (JCA) and Instance B (JCB). When a ticket is escalated from JCA to JCB, it appears within the latter, allowing users to engage with it. 

However, a crucial aspect of maintaining work log consistency arises when a user records time for the ticket in JCB. It becomes essential for this time entry to be accurately mirrored in JCA, ensuring uniformity.

Conversely, if a user changes a work log in JCA, the same change should be synchronized to JCB. This way, everyone involved will always have the latest information across both instances.

The advanced nature of the use case warrants an integration solution that can handle it effortlessly. 

Exalate is a customizable integration solution that supports basic to advanced use cases. 

Exalate: An Integration Solution 

Exalate connects your everyday tools to set up a flexible, bidirectional sync. It supports Jira, GitHub, Salesforce, Zendesk, Azure DevOps, ServiceNow, etc. 

In addition to a drag-and-drop interface for simple use cases, it offers a customizable Script mode that helps set up advanced use cases with ease. 

Exalate’s Script mode has Groovy-based scripts present on both integrating sides. The outgoing script controls what information leaves the platform, and the incoming script controls how to interpret information coming from the destination instance. You can control these scripts independently at both ends, as they are decentralized in nature. 

Before we discuss how to implement this use case with the help of Exalate, let’s quickly understand how Exalate handles Tempo work logs.

How Exalate Interacts with Tempo

If you have installed Tempo already, you can access it under “Apps” in your Jira Cloud instance. 

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. Furthermore, it also allows for the ongoing synchronization of worklog updates, ensuring any modifications made in one instance are accurately reflected in the other. 

Grant Exalate Access to Tempo

Exalate requires access to Tempo. To do so securely, you need to create a user access token. This access token is based on the current permissions of the Jira cloud user. 

You can generate the Access Token under the “Tempo settings > API integration” tab.

The required set of permissions for the user who generates the access token are:

Jira Permissions:
  • Create worklogs
  • View all worklogs
  • Log work for others
  • Work on issues
Tempo Permissions: 
  • View team worklogs
  • Manage team worklogs

With this groundwork, it’s time to implement the synchronization. 

How to Sync Tempo Worklogs Between Two Jira Cloud Instances

To start implementing the use case with Exalate, you must first install it on both the Jira Cloud instances. Then establish a connection in Script mode between them. 

Next, you need to configure the connection. You can do that by clicking the “Configure sync” button after establishing the connection or editing it under the “Connections” tab in the Exalate console. 

You can see the “Rules” tab here. This tab is where the Groovy-based scripts we talked about earlier reside. The “Outgoing sync” contains scripts that control what you want to send to the other Jira. The “Incoming sync” contains scripts that decide how you want to interpret the information coming from the destination Jira. 

Exalate syncing tempo worklogs between two Jira instances

Since we want a bidirectional Tempo worklogs sync between the Jira instances, we must modify both scripts at both ends. 

Outgoing Sync 

The incoming and outgoing sync scripts will remain the same on JCA and JCB. Only the token must be replaced with the actual token that you’ve generated. 

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

Incoming Sync

For the incoming sync, ensure the following: 

  • Add the imports at the beginning of the code.
  • Add the functions at the end of the code.
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)
       }
   }
}

Conclusion

By synchronizing Tempo worklogs between distinct Jira cloud instances using Exalate, organizations can facilitate real-time updates of Tempo worklogs. This bidirectional synchronization not only ensures consistency and accuracy of worklog data but also fosters efficient cross-instance communication, promotes transparency across teams, and elevates the overall success of their projects.

Recommended Reading:

Comments are closed.