1
0
-1

We would like to send ADO workitems to Jira issues. 

Any time tracking information contained in the 7pace time tracker (ADO extension) for that workitem must also be sent over to the Tempo (Jira addon) on the relevant Jira issue. 

    CommentAdd your comment...

    2 answers

    1.  
      2
      1
      0

      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
        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
        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
        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
        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 to see how the solution works.


      Thanks

      Majid


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


        CommentAdd your comment...
      1.  
        1
        0
        -1

        The full code snippets on source and destination are here:


        Jira Outgoing
        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
        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
        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
        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)
        */
        
        
          CommentAdd your comment...