Sync between ADO and Jira while also syncing time data from 7pace to Tempo

Answer by Syed Majid Hassan on 11 August 2022

High level view of the solution:

  • ADO to read the 7pace data and add it to the replica to send to Jira
  • Jira to read the time information sent by ADO and populate it within Tempo.

Step 1:

  • Choose an endpoint to use in order to fetch data from 7pace.
  • Add the GroovyHttpClient class to your ADO outgoing code:

GroovyHttpClient Expand source

class GroovyHttpClient {
    // 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 httpClient

    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
        }
    }

    GroovyHttpClient(httpClient) {
        this.httpClient = httpClient
    }

    String http(String method, String url, String body, java.util.Map<String, List<String>> headers) {
        http(method, url, body, headers) { Response response ->
            if (response.code >= 300) {
                throw new com.exalate.api.exception.IssueTrackerException("Failed to perform the request $method $url (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 _url, String body, java.util.Map<String, List<String>> headers, Closure<R> transformResponseFn) {
        def unsanitizedUrl = _url
        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.collectEntries { k, v -> [k, [v]] } as java.util.Map<String, List<String>>)
                    m
                })()
                : ([:] 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 = ({ 
try { httpClient.azureClient } 
catch (e) { httpClient.issueTrackerClient }  
})()
.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.http.Writeable$.MODULE$.wString(play.api.mvc.Codec.utf_8())
                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 $_url 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
        }
    }
}
  • Run the 7pace API call using the GroovyHttpClient e.g.:

Usage Expand source

def res = new GroovyHttpClient(httpClient)
    .http(
            "GET",
            "https://exalatedemo.timehub.7pace.com/api/odata/v3.1/workLogsOnly",
            null,
            ["Accept": ["application/json"], "Content-type" : ["application/json"], "Authorization":["Basic <<insert access_token here>>"] ]
    ) 
    { 
        response ->
        if (response.code >= 400) 
            throw new com.exalate.api.exception.IssueTrackerException("Failed")
		else 
			response.body as String
    }
  • Parse the received response to fetch the data you want e.g. I am picking up the PeriodLength here only, and add it to the replica. We are also filtering the received data by workItemId in order to get relevant data only:

Parse the response Expand source

def js = new groovy.json.JsonSlurper()
def json = js.parseText(res)
replica.customKeys."PeriodLength" = 0
for(int i=0; i<json.value.size();i++){
    if (json.value[i].WorkItemId.toString() == workItem.key.toString())
        replica.customKeys."PeriodLength" += json.value[i]?.PeriodLength
}

Step 2:

  • On the Jira incoming script, receive the time information from ADO and use the addWorkLog() method to populate it into Tempo:

Jira Incoming Expand source

if (firstSync)
    issue.workLogs = workLogHelper.addWorkLog("2022/08/11", "${((int) (replica.customKeys.'PeriodLength')/60)}m", "Test", issue.workLogs)
else{
    if (previous.customKeys.'PeriodLength' != 0)
        issue.workLogs = workLogHelper.addWorkLog("2022/08/11", "${((((int) (replica.customKeys.'PeriodLength')) - ((int) previous.customKeys.'PeriodLength'))/60)}m", "Test", issue.workLogs)
    else
        issue.workLogs = workLogHelper.addWorkLog("2022/08/11", "${((int) (replica.customKeys.'PeriodLength')/60)}m", "Test", issue.workLogs)
}

Please review the attached video (old community) to see how the solution works.

Thanks

Majid

p.s.: Some more reading on the subject is here.