Map Tempo work log author while syncing

Hello there!

We are using exalate to sync issues from two jira-cloud instances. Whereas, instance A (company A) is used to create and plan the issues. Instance B (company B) is used to track the time on those issues using Tempo work logs. We faced the issue that the “Time Remaining” has not been synced correctly as Tempo and exalate got in a kind of a war over who changes what and when :wink:
The support case we opened was handled and a new and more convenient method was provided to sync the work logs (Thanks for that @Tomas_Lalanne ).

Now we are facing the issue that Tempo throws an error that the account which is used for syncing on the incoming side, is not permitted to write work logs for other users (“The user does not have permission to log work on destination project”). Using the old Script it worked with the same token.
The assumption is that it has something to do with the fact that we need to map the authoring account of the Tempo work log as the accounts differ on instance A and B.

Here are the relevant parts of the scripts,
from the outgoing (instance B)
def userMapping = [
user@companyA.com” : “user@companyB.com
]

replica.workLogs.findAll { it.id == null }
    .each {
        BasicHubWorkLog worklog ->
            try {
                log.info("processing replica work log with author: " + worklog.author.email)
                def getUser = { String email ->
                    log.info("search mapping for email: " + email)
                    def mappedEmail = userMapping[email]
                    def localAuthor = null
                    if (mappedEmail != null) {
                        log.info("found mapping: " + mappedEmail)
                        localAuthor = nodeHelper.getUserByEmail(mappedEmail)
                    }
                    localAuthor
                }

                def mappedAuthor = getUser(worklog.author.email)
                if (mappedAuthor != null) {
                    log.info("using mapped author: " + mappedAuthor.email)
                    worklog.author = mappedAuthor
                    worklog.updateAuthor = worklog.updateAuthor ? getUser(getUser(worklog.updateAuthor.email)) : null
                } else {
                    null
                }
            } catch (com.exalate.api.exception.IssueTrackerException ite) {
                throw ite
            } catch (Exception e) {
                throw new com.exalate.api.exception.IssueTrackerException(e)
            }
        }
replica.workLogs.findAll { it.id == null }
    .each {
        BasicHubWorkLog worklog -> log.info("Replica work log for issue " + replica.key + " has author " + worklog.author)
    }

// The new sync method
TempoWorkLogSync.receive(
“”,
replica,
issue,
httpClient,
traces,
nodeHelper
)

and incoming (instance A)
replica.workLogs = issue.workLogs
TempoWorkLogSync.send(
“”,
replica,
issue,
httpClient,
nodeHelper
)

Any help would be highly appreciated.

@Tomas_Lalanne In the support ticket you mentioned that you already worked on a script for the mapping. Do you have any update or hint for me how to proceed?

Hi @stoetti !
Sorry for the delay but we finally got the solution.

On the receiving end, we have to remove the script and replace it with the next

TempoWorkLogSync.receive(Here goes your token from Tempo)

import com.exalate.api.domain.twintrace.TraceAction
import com.exalate.api.domain.twintrace.TraceType
import com.exalate.api.domain.webhook.WebhookEntityType
import com.exalate.api.exception.IssueTrackerException
import com.exalate.basic.domain.BasicNonPersistentTrace
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.domain.http.GroovyHttpRequest
import com.exalate.processor.ExalateProcessor
import com.exalate.replication.services.replication.impersonation.AuditLogService
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
import jcloudnode.services.jcloud.hubobjects.NodeHelper
import jcloudnode.services.replication.PreparedHttpClient
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import play.api.libs.ws.WSBodyWritables$
import play.libs.Json
import scala.Option
import scala.Option$
import scala.Tuple2
import scala.Tuple2$
import scala.collection.JavaConverters
import scala.collection.immutable.Seq
import scala.concurrent.Await$
import scala.concurrent.Future
import scala.concurrent.duration.Duration$

import java.text.SimpleDateFormat
import java.time.Instant

class TempoWorkLogSync {
private static Option option(T nullable) { Option$.MODULE$. apply(nullable) }
private static <T1, T2> Tuple2<T1, T2> pair(T1 l, T2 r) { Tuple2$.MODULE$.apply(l, r) }
private static Seq seq(T … ts) { JavaConverters.asScalaBuffer(Arrays.asList(ts)).toSeq() }
private static T await(Future f) { Await$.MODULE$.result(f, Duration$.MODULE$.Inf()) }

private static String tempoRestApiUrl = "https://api.tempo.io/4"

private static Logger LOG = LoggerFactory.getLogger("com.exalate.script")

private static SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'")
private static SimpleDateFormat dateOnlyFormatter = new SimpleDateFormat("yyyy-MM-dd")

private static Date parseDate(String dateStr) {
    try {
        formatter.parse(dateStr)
    } catch (_1) {
        try {
            dateOnlyFormatter.parse(dateStr)
        } catch (_2) {
            try {
                Date.from(Instant.parse(dateStr))
            } catch (_3) {
                LOG.error("#parseDate failed to parse Date `$dateStr` via formatter `${formatter.toPattern()}`", _1)
                LOG.error("#parseDate failed to parse Date `$dateStr` via date formatter `${dateOnlyFormatter.toPattern()}`", _2)
                LOG.error("#parseDate failed to parse Date `$dateStr` via java.time.Instant", _3)
                null
            }
        }
    }
}

private static String http(String method, String url, Seq<Tuple2<String, String>> queryParams, String body, Seq<Tuple2<String, String>> headers) {
    def context = ExalateProcessor.CURRENT_BINDINGS.get()
    def response
    try {
        def request = context.httpClient.ws().url(url).addQueryStringParameters(queryParams).addHttpHeaders(headers)
        if (body != null) {
            request = request.withBody(body, WSBodyWritables$.MODULE$.writeableOf_String())
        }
        response = await(request.execute(method))
    } catch (Exception e) {
        throw new IssueTrackerException("Unable to perform the request $method $url, please contact Exalate Support: ".toString() + e.message, e)
    }
    if (response.status() >= 300) {
        throw new IssueTrackerException("Failed to perform the request $method $url (status ${response.status()}), please contact Exalate Support: ".toString() + response.body())
    }
    return response.body()
}

static send(String token, BasicHubIssue replica, BasicHubIssue issue, PreparedHttpClient httpClient, NodeHelper nodeHelper) {
    def js = new JsonSlurper()
    def fetchAllWorklogs
    fetchAllWorklogs = { String url, Seq<Tuple2<String, String>> queryParams, List<BasicHubWorkLog> allResults, Map<String, Map<String, Object>> listAdditionalParams ->
        def response = js.parseText(http(
                "GET",
                url,
                queryParams,
                null,
                seq(pair("Authorization", "Bearer ${token}".toString())))) as Map<String, Object>

        // Process retrieved worklogs
        response.get("results").each { Map<String, Object> responseItem ->
            BasicHubWorkLog basicHubWorkLog = new BasicHubWorkLog()
            // author
            def tempoAuthor = responseItem.author as Map<String, String>
            BasicHubUser author = nodeHelper.getUser("712020:57b71634-6f5c-4849-8c47-65c5403ed492" as String)
            basicHubWorkLog.setAuthor(author)
            // create
            basicHubWorkLog.setCreated(parseDate(responseItem.createdAt as String))
            // comment
            basicHubWorkLog.setComment(responseItem.description as String)
            // id
            basicHubWorkLog.setId(responseItem.tempoWorklogId as Long)
            // startDate
            basicHubWorkLog.setStartDate(parseDate(responseItem.startDate as String))
            // timeSpent
            basicHubWorkLog.setTimeSpent(responseItem.timeSpentSeconds as Long)
            // updated
            basicHubWorkLog.setUpdated(parseDate(responseItem.updatedAt as String))

            allResults.add(basicHubWorkLog)

            listAdditionalParams.put(responseItem.tempoWorklogId.toString(), [
                    billableSeconds         : responseItem.get("billableSeconds"),
                    startTime               : responseItem.get("startTime"),
                    jiraWorklogId           : responseItem.get("jiraWorklogId"),
                    attributes              : responseItem.get("attributes"),
                    issue                   : responseItem.get("issue"),
                    remainingEstimateSeconds: issue.remainingEstimate,
            ])
        }

        // If there are more pages, recursively fetch them
        def nextUrl = response.metadata.next
        if (nextUrl) {
            fetchAllWorklogs(nextUrl, seq(), allResults, listAdditionalParams)
        }
    }

    // Start with an empty list to accumulate results
    List<BasicHubWorkLog> allResults = []
    def listAdditionalParams = [:]

    // Start fetching worklogs recursively
    fetchAllWorklogs(tempoRestApiUrl + "/worklogs", seq(pair("issueId", issue.id.toString()), pair("limit", "1000")), allResults, listAdditionalParams)
    replica.timeSpent = issue.timeSpent
    replica.originalEstimate = issue.originalEstimate
    replica.remainingEstimate = issue.remainingEstimate
    replica.workLogs = allResults
    replica.customKeys."tempoWorklogParams" = transformMap(listAdditionalParams)
}

static private def transformMap(lazyMap) {
    def map = [:]
    for (prop in lazyMap) {
        def value = prop.value
        Class<?> lazyMapClass = null;
        try {
            lazyMapClass = Class.forName("groovy.json.internal.LazyMap")
        } catch (ClassNotFoundException e) {
            lazyMapClass = Class.forName("org.apache.groovy.json.internal.LazyMap")
        }
        if (lazyMapClass.isInstance(prop.value)) {
            value = transformMap(prop.value)
        } else if (prop.value instanceof Map) {
            value = transformMap(prop.value)
        }
        map[prop.key] = value
    }
    return map
}

static send(String token) {
    def context = ExalateProcessor.CURRENT_BINDINGS.get()
    def replica = context.replica
    def issue = context.issue
    def httpClient = context.httpClient
    def nodeHelper = context.nodeHelper
    send(token, replica, issue, httpClient, nodeHelper)
}

static receive(String token) {
    def context = ExalateProcessor.CURRENT_BINDINGS.get()
    def replica = context.replica
    def issue = context.issue
    def traces = context.traces
    def httpClient = context.httpClient
    def nodeHelper = context.nodeHelper
    def projectKey = issue.project?.key ?: issue.projectKey
    def issueType = issue.type ?: ({ nodeHelper.getIssueType(issue.typeName) })()
    if (projectKey == null) {
        throw new IssueTrackerException(""" Project key is not found. Please fill issue.projectKey or issue.project parameter in script """.toString())
    }
    if (issueType == null) {
        throw new IssueTrackerException(""" Issue type is not found. Please fill issue.typeName or issue.type parameter in script """.toString())
    }

    receive(token, replica, issue, httpClient, traces, nodeHelper)
}

static receive(String token, BasicHubIssue replica, BasicHubIssue issue, PreparedHttpClient httpClient, traces, NodeHelper 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 = "here goes the key of the user that you want to be the owner of the worklog"
                    }
                    localAuthor
                }
                w.author = getUser("here goes the key of the user that you want to be the owner of the worklog")
                w.updateAuthor = w.updateAuthor ? getUser(w.updateAuthor.key) : null
                w
            }
    )
}

static receive(String token, BasicHubIssue replica, BasicHubIssue issue, httpClient, traces, nodeHelper, Closure<?> onWorklogFn) {
    def context = ExalateProcessor.CURRENT_BINDINGS.get()

    issue.originalEstimate = replica.originalEstimate
    issue.remainingEstimate = replica.remainingEstimate

    if (context.firstSync) {
        context.store(issue)
    }

    issue = context.issue
    traces = context.traces

    def gsp = context.injector.instanceOf(AuditLogService.class)

    def listAdditionalParams = replica.customKeys."tempoWorklogParams" as Map<String, Map<String, Object>>;

    def tempoUsers = httpClient.get("/rest/api/3/user/search", [query: "Kevinho"])
    LOG.info("Tempo users: " + tempoUsers)
    def tempoUserId = tempoUsers.find({user -> user.displayName == "Kevinho Pozuelo" && user.accountType == "atlassian"})?.accountId
    LOG.info("Tempo user id: " + tempoUserId)

    replica.getAddedWorkLogs().each { BasicHubWorkLog worklog ->
        def transformedWorklog

        try {
            transformedWorklog = onWorklogFn(worklog)
        } catch (IssueTrackerException ite) {
            throw ite
        } catch (Exception e) {
            throw new IssueTrackerException(e)
        }

        if (transformedWorklog instanceof BasicHubWorkLog) {
            worklog = transformedWorklog as BasicHubWorkLog
        } else if (transformedWorklog == null) {
            return
        }

        // Audit log helps to ignore webhooks triggered by new tempo worklogs
        // See com.exalate.replication.services.replication.impersonation.ImpersonationService
        // if we don't ignore webhooks in two way sync scenario then we get sync cycle with duplicated worklogs
        def auditLog = gsp.createAuditLog(option(issue.id as String), WebhookEntityType.WORKLOG_CREATED, tempoUserId)

        def additionalParams = listAdditionalParams?.get(worklog.remoteId.toString())
        def attributes = ((additionalParams?.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 tempoWorklog = new JsonSlurper().parseText(http(
                "POST",
                tempoRestApiUrl + "/worklogs",
                seq(),
                new JsonOutput().toJson([
                        issueId                 : issue.id,
                        timeSpentSeconds        : worklog.getTimeSpent(),
                        billableSeconds         : additionalParams?.get("billableSeconds") ?: worklog.getTimeSpent(),
                        startDate               : new SimpleDateFormat("yyyy-MM-dd").format(worklog.startDate),
                        startTime               : new SimpleDateFormat("hh:mm:ss").format(worklog.startDate),
                        description             : worklog.getComment(),
                        authorAccountId         : worklog.getAuthor().getKey(),
                        remainingEstimateSeconds: replica.remainingEstimate ?: additionalParams?.get("remainingEstimateSeconds"),
                        attributes              : attributes]),
                seq(pair("Authorization", "Bearer ${token}".toString()),
                    pair("Content-Type", "application/json"))))
        LOG.info("Tempo worklog created: " + tempoWorklog)
        def tempoWorklogId = tempoWorklog.tempoWorklogId as String
        def jiraWorklogId = waitForJiraWorklog(tempoWorklogId, token, 9)

        gsp.updateAuditLog(option(auditLog), issue.id as String, jiraWorklogId, Json.stringify(Json.toJson(tempoWorklog)))

        traces.add(new BasicNonPersistentTrace()
                .setLocalId(tempoWorklogId)
                .setRemoteId(worklog.remoteId.toString())
                .setType(TraceType.WORKLOG)
                .setAction(TraceAction.NONE)
                .setToSynchronize(true))
    }
}

private static String waitForJiraWorklog(String tempoWorklogId, String token, long retries) {
    def response = new JsonSlurper().parseText(http(
            "POST",
            tempoRestApiUrl + "/worklogs/tempo-to-jira",
            seq(),
            new JsonOutput().toJson([tempoWorklogIds: [tempoWorklogId]]),
            seq(pair("Authorization", "Bearer ${token}".toString()),
                pair("Content-Type", "application/json"))))
    LOG.info("Tempo worklog $tempoWorklogId to jira: " + response)
    def jiraWorklogId = response.results?.find()?.jiraWorklogId as String
    if (jiraWorklogId == null && retries > 0) {
        LOG.info("No jira workflow for tempo $tempoWorklogId, retrying...")
        return waitForJiraWorklog(tempoWorklogId, token, retries - 1)
    } else {
        return jiraWorklogId
    }
}

}

Also remember to put the view on all data, if not you will only see what you posted not all
BR
Tomas

2 Likes