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