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

**URL:** <https://community.exalate.com/t/sync-between-ado-and-jira-while-also-syncing-time-data-from-7pace-to-tempo/5271>\
**Category:** General Questions\
**Tags:** exalate, azuredevops, ado, httpclient, adops\
**Created:** [November 4, 2024, 7:54am UTC](https://community.exalate.com/t/sync-between-ado-and-jira-while-also-syncing-time-data-from-7pace-to-tempo/5271 "2024-11-04T07:54:43Z")\
**Posts on this page:** 4\
**Page:** 1

<div class="post-metadata">

**Author:** ![xl8bot](https://dub1.discourse-cdn.com/flex018/user_avatar/community.exalate.com/xl8bot/32/324_2.png) [@xl8bot](https://community.exalate.com/u/xl8bot)\
**Post date:** [November 4, 2024, 7:54am UTC](https://community.exalate.com/t/sync-between-ado-and-jira-while-also-syncing-time-data-from-7pace-to-tempo/5271/1 "2024-11-04T07:54:43Z")

</div>

_Originally asked by Syed Majid Hassan on 11 August 2022 [(original question)](https://oldcommunity.exalate.com/questions/58493434)_

* * *

We would like to send ADO workitems to Jira issues.

Any time tracking information contained in the [7pace](https://www.7pace.com/) time tracker (ADO extension) for that workitem must also be sent over to the [Tempo](https://www.tempo.io/) (Jira addon) on the relevant Jira issue.

* * *

---

<div class="post-metadata">

**Author:** ![xl8bot](https://dub1.discourse-cdn.com/flex018/user_avatar/community.exalate.com/xl8bot/32/324_2.png) [@xl8bot](https://community.exalate.com/u/xl8bot)\
**Post date:** [November 4, 2024, 7:54am UTC](https://community.exalate.com/t/sync-between-ado-and-jira-while-also-syncing-time-data-from-7pace-to-tempo/5271/2 "2024-11-04T07:54:44Z")

</div>

_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](https://support.7pace.com/hc/en-us/articles/360035502332-7pace-Timetracker-Reporting-API-Version-3) to use in order to fetch data from 7pace.
- Add the GroovyHttpClient class to your ADO outgoing code:

**GroovyHttpClient** Expand source

````auto
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

```auto
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

```auto
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()](https://docs.idalko.com/exalate/display/ED/addWorkLog) method to populate it into Tempo:

**Jira Incoming** Expand source

```auto
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](https://oldcommunity.exalate.com/download/attachments/58493434/ADO%20to%20Jira%20-%20with%207pace%20and%20Tempo.mp4?version=1&modificationDate=1660239256431&api=v2)_(old community)_ to see how the solution works.

Thanks

Majid

p.s.: Some more reading on the subject is [here](https://docs.idalko.com/exalate/display/ED/Syncing+worklogs+with+Tempo+Timesheets).

* * *

---

<div class="post-metadata">

**Author:** ![xl8bot](https://dub1.discourse-cdn.com/flex018/user_avatar/community.exalate.com/xl8bot/32/324_2.png) [@xl8bot](https://community.exalate.com/u/xl8bot)\
**Post date:** [November 4, 2024, 7:54am UTC](https://community.exalate.com/t/sync-between-ado-and-jira-while-also-syncing-time-data-from-7pace-to-tempo/5271/3 "2024-11-04T07:54:45Z")

</div>

_Answer by Syed Majid Hassan on 12 August 2022_

The full code snippets on source and destination are here:

**Jira Outgoing** Expand source

```auto
replica.key = issue.key
replica.type = issue.type
replica.assignee = issue.assignee
replica.reporter = issue.reporter
replica.summary = issue.summary
replica.description = issue.description
replica.labels = issue.labels
replica.comments = issue.comments
replica.resolution = issue.resolution
replica.status = issue.status
replica.parentId = issue.parentId
replica.priority = issue.priority
replica.attachments = issue.attachments
replica.project = issue.project

//Comment these lines out if you are interested in sending the full list of versions and components of the source project. 
replica.project.versions = []
replica.project.components = []

/*
Custom Fields

replica.customFields."CF Name" = issue.customFields."CF Name"
*/

```

**Jira Incoming** Expand source

```auto
if(firstSync){
   issue.projectKey = "CM" 
   issue.typeName = nodeHelper.getIssueType(replica.type?.name, issue.projectKey)?.name ?: "Task"
}
issue.summary = replica.summary
issue.description = replica.description
issue.comments = commentHelper.mergeComments(issue, replica)
issue.attachments = attachmentHelper.mergeAttachments(issue, replica)
issue.labels = replica.labels

if (firstSync)
    issue.workLogs = workLogHelper.addWorkLog("2022/08/11", "${((int) (replica.customKeys.'PeriodLength'))}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)
}

```

**ADO Outgoing** Expand source

````auto
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
        }
    }
}
replica.key = workItem.key
replica.assignee = workItem.assignee 
replica.summary = workItem.summary
replica.description = nodeHelper.stripHtml(workItem.description)
replica.type = workItem.type
replica.status = workItem.status
replica.labels = workItem.labels
replica.priority = workItem.priority
replica.comments = nodeHelper.stripHtmlFromComments(workItem.comments)
replica.attachments = workItem.attachments
replica.project = workItem.project
replica.areaPath = workItem.areaPath
replica.iterationPath = workItem.iterationPath

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 cm9tYW4ubWVsbnljaGVua29AaWRhbGtvLmNvbTp4dzZVVGIyTV8zdngzd0FqMWt3aVpLSVpjYkN5NjZNbGE0Q2hHLWU1Wl9v"] ]
    ) 
    { 
        response ->
        if (response.code >= 400) {
            throw new com.exalate.api.exception.IssueTrackerException("Failed to get orgnaizations with name ")
        } else response.body as String
    }
            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())
                //debug.error("${workItem.key}")
                    replica.customKeys."PeriodLength" += json.value[i]?.PeriodLength
            }
           

````

**ADO Incoming** Expand source

```auto
workItem.labels = replica.labels
workItem.priority = replica.priority
if(firstSync){
   // Set type name from source entity, if not found set a default
   workItem.typeName = nodeHelper.getIssueType(replica.type?.name)?.name ?: "Task";
}

workItem.summary = replica.summary
workItem.description = replica.description
workItem.attachments = attachmentHelper.mergeAttachments(workItem, replica)
workItem.comments = commentHelper.mergeComments(workItem, replica)
/*
Area Path Sync
This also works for iterationPath field

Set Area Path Manually
workItem.areaPath = "Name of the project\\name of the area"

Set Area Path based on remote side drop-down list
Change "area-path-select-list" to actual custom field name
workItem.areaPath = replica.customFields."area-path-select-list"?.value?.value

Set Area Path based on remote side text field
Change "area-path" to actual custom field name
workItem.areaPath = replica.customFields."area-path".value
*/

/*
Status Synchronization

Sync status according to the mapping [remote workItem status: local workItem status]
If statuses are the same on both sides don"t include them in the mapping
def statusMapping = ["Open":"New", "To Do":"Open"]
def remoteStatusName = replica.status.name
workItem.setStatus(statusMapping[remoteStatusName] ?: remoteStatusName)
*/

```

* * *

---

<div class="post-metadata">

**Author:** ![xl8bot](https://dub1.discourse-cdn.com/flex018/user_avatar/community.exalate.com/xl8bot/32/324_2.png) [@xl8bot](https://community.exalate.com/u/xl8bot)\
**Post date:** [November 6, 2024, 7:55am UTC](https://community.exalate.com/t/sync-between-ado-and-jira-while-also-syncing-time-data-from-7pace-to-tempo/5271/4 "2024-11-06T07:55:23Z")

</div>

This topic was automatically closed 2 days after the last reply. New replies are no longer allowed.
