Introduction
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;
Outgoing Sync Jira
replica.customFields."Sprint" = issue.customFields."Sprint"
Sprint Data
"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;
Incoming Sync Azure Devops
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 for the same;