Hello all,
The scene:
You have set up a succesful integration between Jira and Azure Devops. You want to integrate Sprints aswell. In Jira (any deployment), the sprints already exist, but in Azure Devops, it could be that the sprints don't exist yet.
Here is where Exalate can help you out. With the use of Exalate, we can create the new sprints in Azure Devops.
The first thing we need to do is to send out all the information about a Sprint from the Jira outgoing side.
If we have a look at the data that will be sent out to Azure Devops, this is how it looks:
If we add this line of code in the Outgoing Sync in Jira;
replica.customFields."Sprint" = issue.customFields."Sprint" |
"customFields": { "Sprint": { "id": 10104, "name": "Sprint", "uid": "10104", "description": "Jira Software sprint field", "type": "SPRINTS", "value": [ { "id": "2", "state": "FUTURE", "name": "SprintTwo", "originBoardId": "7", "startDate": 1697476800000, "endDate": 1698686400000, "sequence": 2, "goal": "This sprint does not exist yet in ADO", "eventTriggerContext": {} } ] } }, |
Exalate will use this information to create a Sprint in Azure Devops.
In Azure Devops we need to add this code to the incoming sync.
Please be mindful that you need to change the projectKey after implementing following code;
if(firstSync){ // Set type name from source entity, if not found set a default workItem.projectKey = "Mathieu" def typeMap = [ "Epic" : "Epic", "Story" : "User Story" ] workItem.typeName = nodeHelper.getIssueType(typeMap[replica.type?.name],workItem.projectKey)?.name ?: "Task" workItem.summary = replica.summary store(issue) } if(workItem.typeName == "Task"){ workItem.summary = replica.summary workItem.description = replica.description workItem.attachments = attachmentHelper.mergeAttachments(workItem, replica) workItem.comments = commentHelper.mergeComments(workItem, replica) workItem.labels = replica.labels workItem.priority = replica.priority } def getCurrentSprint = { -> replica."Sprint".find {!it.state.equalsIgnoreCase("CLOSED")} } if (replica.customFields."Sprint"?.value != null && !replica.customFields."Sprint"?.value?.empty && getCurrentSprint() != null) { def project = connection.trackerSettings.fieldValues."project" def area = workItem.areaPath ?: workItem.project?.key ?: project def sprint = getCurrentSprint() def iteration = sprint.name def iterationPath = area + "\\" + iteration if (iterationPath != workItem.iterationPath) { def adoClient = new AdoClient(httpClient, nodeHelper, debug) def encode = { str -> if (!str) str else java.net.URLEncoder.encode(str, java.nio.charset.StandardCharsets.UTF_8.toString()) } def projectName = workItem.project?.key ?: workItem.projectKey def existingIterations = adoClient .http ( "GET", "/${encode(projectName)}/_apis/wit/classificationnodes/Iterations".toString(), ["api-version":["5.0"], "\$depth":["1"]], null, ["Accept":["application/json"]] ) { res -> if(res.code >= 400) debug.error("Failed to GET /${encode(projectName)}/_apis/work/teamsettings/iterations?api-version=7.1-preview.1 RESPONSE: ${res.code} ${res.body}") else (new groovy.json.JsonSlurper()).parseText(res.body) } ?.children if (!existingIterations.name.any {it.equalsIgnoreCase(sprint.name)}) { //if we need to create iterations def await = { f -> scala.concurrent.Await$.MODULE$.result(f, scala.concurrent.duration.Duration.apply(1, java.util.concurrent.TimeUnit.MINUTES)) } def creds = await(httpClient.azureClient.getCredentials()) def token = creds.accessToken() def baseUrl = creds.issueTrackerUrl() def dateFormat = new java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'") def createIterationBody = [name:sprint.name] def attributes = null if(sprint.startDate) { def sd = dateFormat.format(sprint.startDate) attributes = ["startDate":sd] } if(sprint.endDate) { def ed = dateFormat.format(sprint.endDate) attributes = attributes ?: [:] attributes."finishDate" = ed } if(attributes != null) { createIterationBody."attributes" = attributes } def remoteIterationName = sprint.name def iterationId = adoClient .http ( "POST", "/${encode(projectName)}/_apis/wit/classificationnodes/Iterations".toString(), ["api-version":["5.0"]], groovy.json.JsonOutput.toJson(["name": remoteIterationName]), ["Accept":["application/json"], "Content-Type":["application/json"]] ) { res -> if(res.code >= 400) debug.error("POST ${encode(projectName)}/_apis/wit/classificationnodes/Iterations?api-version=5.0 failed: ${res.code} ${res.body}") else (new groovy.json.JsonSlurper()).parseText(res.body) }?."identifier" //and associate it with the team adoClient .http ( "POST", "/${encode(projectName)}/_apis/work/teamsettings/iterations".toString(), ["api-version":["7.1-preview.1"]], groovy.json.JsonOutput.toJson(["id": iterationId]), ["Accept":["application/json"], "Content-Type":["application/json"]] ) { res -> if(res.code >= 400) debug.error("POST ${encode(projectName)}/_apis/work/teamsettings/iterations failed: ${res.code} ${res.body}") else (new groovy.json.JsonSlurper()).parseText(res.body) } } workItem.iterationPath = iterationPath } } class AdoClient { // 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 static def getGeneralSettings() { def gsOptFuture = nodeHelper.azureClient.generalSettingsService.get() def gsOpt = await(gsOptFuture) def gs = orNull(gsOpt) gs } private static String getIssueTrackerUrl() { final def gs = getGeneralSettings() def removeTailingSlash = { String str -> str.trim().replace("/+\$", "") } final def issueTrackerUrl = removeTailingSlash(gs.issueTrackerUrl) issueTrackerUrl } private httpClient private static nodeHelper private debug 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 $e ", e) } parsedUri } } AdoClient(httpClient, nodeHelper, debug) { this.httpClient = httpClient this.nodeHelper = nodeHelper this.debug = debug } String http(String method, String path, java.util.Map<String, List<String>> queryParams, String body, java.util.Map<String, List<String>> headers) { http(method, path, queryParams, body, headers) { Response response -> if (response.code >= 300) { throw new com.exalate.api.exception.IssueTrackerException( """Failed to perform the request $method $path (status ${response.code}), and body was: ```$body``` Please contact Exalate Support: """.toString() + response.body ) } response.body as String } } public <R> R http(String method, String path, java.util.Map<String, List<String>> queryParams, String body, java.util.Map<String, List<String>> headers, Closure<R> transformResponseFn) { def gs = getGeneralSettings() def unsanitizedUrl = issueTrackerUrl + path 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 as java.util.Map<String, List<String>>) m.putAll(queryParams) })() : (queryParams ?: [:] 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) //debug.error("#debug ${sanitizedUrl}") def response try { def request = ({ try { httpClient.azureClient } catch (e) { httpClient.issueTrackerClient } })() .ws .url(sanitizedUrl) .withMethod(method) if (!allQueryParams.isEmpty()) { def scalaQueryParams = scala.collection.JavaConversions.asScalaBuffer( queryParams .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 (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 (body != null) { def writable = play.api.libs.ws.WSBodyWritables$.MODULE$.writeableOf_String() request = request.withBody(body, writable) } def credentials = await(httpClient.azureClient.credentials) def token = credentials.accessToken //debug.error("${play.api.libs.ws.WSAuthScheme$.class.code}") request = request.withAuth(token, token, play.api.libs.ws.WSAuthScheme$BASIC$.MODULE$) response = await(request.execute()) } catch (Exception e) { throw new com.exalate.api.exception.IssueTrackerException( """Unable to perform the request $method $path with body:```$body```, 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 } } } |
Here is a video;
Thank you and happy exalating.