How to Sync Sprint from Jira to Azure Devops

Originally asked by kalyan on 26 May 2022 (original question)


Hi All,

We are trying to synchronize sprints from Jira to Azur Devops.

Below is the requirement:

  • We create Sprint under Jira, add issues , work items under sprint.
  • The sprint, tasks under have to be synchronized to Azure Devops.

Below are the things we tried:

  • Create a scripted connection between Jira, Azure devops.
  • Create a trigger with Entity Type set to Sprint.
  • Add, Update Outgoing script in Jira to Synchronize Sprint data to Azure Devops.
  • Add, Update Incoming script in Azure Devops to synchronize incoming Sprints data from Jira.

But we had errors in Jira, not able to Synch Sprint data.

Added below outgoing Script in Jira:

def boardIds = [“1”] //Boards which sprints will get synced
if(entityType == “sprint” && boardIds.find{it == sprint.originBoardId}){
replica.name = sprint.name
replica.goal = sprint.goal
replica.state = sprint.state
replica.startDate = sprint.startDate
replica.endDate = sprint.endDate
replica.originBoardId = sprint.originBoardId
}

Added below Incoming Script in Azure devops:

if(entityType == “sprint”){
//Executed when receiving a sprint sync from the remote side
def sprintMap = [“1”:“2”] //[remoteBoardId: localBoardId]

[sprint.name](http://sprint.name) \= [replica.name](http://replica.name)  
sprint.goal \= replica.goal  
sprint.state \= replica.state  
sprint.startDate \= replica.startDate  
sprint.endDate \= replica.endDate  
def localBoardId \= sprintMap\[replica.originBoardId]  
if(localBoardId \=\= null){  
   throw new com.exalate.api.exception.IssueTrackerException("No board mapping for remote board id "\+replica.originBoardId)  
}  
sprint.originBoardId \= localBoardId //Set the board ID where the sprint will be created  

}

Please let me know your valuable suggestions.
Thank you.


Comments:

Serhiy Onyshchenko commented on 28 May 2022

Hello, kalyan
documenting something for my future self, concerning your question:

JIRA OUT

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 = []

replica.customFields."Sprint" = issue.customFields."Sprint"
/*
Custom Fields

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

ADO IN

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)
def getCurrentSprint = { -> replica."Sprint".find {!it.state.equalsIgnoreCase("CLOSED")} }
if (replica."Sprint" != null && !replica."Sprint".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) {
      //debug.error("fV.p=${connection.fieldValues."project"}")
      //find existing iterations
      //debug.error("methods=${httpClient.getClass().declaredMethods}")
      def existingIterations = httpClient.get("/${project}/_apis/wit/classificationnodes/Iterations?\$depth=1&api-version=5.0", false)?.children
      /*httpClient.http (
          "GET",
          "/${project}/_apis/wit/classificationnodes".toString(),
          null,
          ["\$depth":["1"],"api-version":["5.0"]],
          [:]
        ) { response ->
          if (response.code >= 300) { debug.error("GET /${project}/_apis/wit/classificationnodes/Iterations?\$depth=1&api-version=5.0 failed: ${response.body}") }
          def json = response.body
          if (json.children == null) {
              debug.error("GET /${project}/_apis/wit/classificationnodes/Iterations?\$depth=1&api-version=5.0 didn't find iteration paths: ${response.body}")
          }
          json.children
      }*/
      
        //debug.error("existingIterations=${existingIterations}")
      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 createIterationBodyStr = groovy.json.JsonOutput.toJson(createIterationBody)
          def result = await(httpClient.azureClient.ws
            .url(baseUrl+"/${project}/_apis/wit/classificationnodes/Iterations?api-version=5.0")
            .withAuth(token, token, play.api.libs.ws.WSAuthScheme$BASIC$.MODULE$)
            .withBody(play.api.libs.json.Json.parse(createIterationBodyStr), play.api.libs.ws.JsonBodyWritables$.MODULE$.writeableOf_JsValue)
            //.addHttpHeaders([scala.Tuple2.apply("Content-Type", "applicatio")])
            .withMethod("POST")
            .execute())
        debug.error("result=${result} status=${result.status}")
          //httpClient.post("/${project}/_apis/wit/classificationnodes/Iterations?api-version=5.0".toString(), createIterationBodyStr)
          /*httpClient.http(
              "POST",
              "/${project}/_apis/wit/classificationnodes/Iterations".toString(),
              createIterationBodyStr,
              ["api-version":["5.0"]],
              [:]
            ) { response ->
              if (response.code >= 300) { debug.error("POST /${project}/_apis/wit/classificationnodes/Iterations?api-version=5.0 with body: ${createIterationBodyStr} failed: ${response.body}") }
              def json = response.body
              if (json.id == null) {
                  debug.error("POST /${project}/_apis/wit/classificationnodes/Iterations?api-version=5.0 with body: ${createIterationBodyStr} didn't create a sprint: ${response.body}")
              }
              json
          }*/
      }
      
      workItem.iterationPath = iterationPath
  }
  //debug.error("it=${workItem.iterationPath}")
}
/*
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)
*/


Regards, Serhiy

kalyan commented on 02 June 2022

Hi Serhiy,

Thank you.

I am facing below issue in Jira after adding above script from your reply.

Created below trigger in Jira:

Jira Out:

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 =

replica.customFields.“Sprint” = issue.customFields.“Sprint”
/*
Custom Fields

replica.customFields.“CF Name” = issue.customFields.“CF Name”
*/

Below is the error:

Error Stack Trace

com.exalate.api.exception.script.ScriptException: No such property: issue for class: Script24 at com.exalate.error.services.ScriptExceptionCategoryService.categorizeProcessorAndIssueTrackerExceptionsIntoScriptExceptions(ScriptExceptionCategoryService.scala:40) at com.exalate.processor.ExalateProcessor.executeProcessor(ExalateProcessor.java:57) at com.exalate.replication.services.processor.CreateReplicaProcessor.$anonfun$executeScriptRules$2(CreateReplicaProcessor.scala:119) at scala.util.Try$.apply(Try.scala:213) at com.exalate.replication.services.processor.CreateReplicaProcessor.executeScriptRules(CreateReplicaProcessor.scala:117) at com.exalate.replication.services.processor.CreateReplicaProcessor.$anonfun$createHubReplica$2(CreateReplicaProcessor.scala:69) at scala.concurrent.Future.$anonfun$flatMap$1(Future.scala:307) at scala.concurrent.impl.Promise.$anonfun$transformWith$1(Promise.scala:41) at scala.concurrent.impl.CallbackRunnable.run(Promise.scala:64) at akka.dispatch.BatchingExecutor$AbstractBatch.processBatch(BatchingExecutor.scala:56) at akka.dispatch.BatchingExecutor$BlockableBatch.$anonfun$run$1(BatchingExecutor.scala:93) at scala.runtime.java8.JFunction0$mcV$sp.apply(JFunction0$mcV$sp.java:23) at scala.concurrent.BlockContext$.withBlockContext(BlockContext.scala:85) at akka.dispatch.BatchingExecutor$BlockableBatch.run(BatchingExecutor.scala:93) at akka.dispatch.TaskInvocation.run(AbstractDispatcher.scala:48) at akka.dispatch.ForkJoinExecutorConfigurator$AkkaForkJoinTask.exec(ForkJoinExecutorConfigurator.scala:48) at java.base/java.util.concurrent.ForkJoinTask.doExec(ForkJoinTask.java:290) at java.base/java.util.concurrent.ForkJoinPool$WorkQueue.topLevelExec(ForkJoinPool.java:1020) at java.base/java.util.concurrent.ForkJoinPool.scan(ForkJoinPool.java:1656) at java.base/java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1594) at java.base/java.util.concurrent.ForkJoinWorkerThread.run(ForkJoinWorkerThread.java:183) Caused by: javax.script.ScriptException: javax.script.ScriptException: groovy.lang.MissingPropertyException: No such property: issue for class: Script24 at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:158) at java.scripting/javax.script.AbstractScriptEngine.eval(AbstractScriptEngine.java:264) at com.exalate.processor.ExalateProcessor.execute(ExalateProcessor.java:98) at com.exalate.processor.ExalateProcessor.executeProcessor(ExalateProcessor.java:55) … 19 more Caused by: javax.script.ScriptException: groovy.lang.MissingPropertyException: No such property: issue for class: Script24 at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:320) at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:155) … 22 more Caused by: groovy.lang.MissingPropertyException: No such property: issue for class: Script24 at org.codehaus.groovy.runtime.ScriptBytecodeAdapter.unwrap(ScriptBytecodeAdapter.java:65) at org.codehaus.groovy.runtime.callsite.PogoGetPropertySite.getProperty(PogoGetPropertySite.java:51) at org.codehaus.groovy.runtime.callsite.AbstractCallSite.callGroovyObjectGetProperty(AbstractCallSite.java:309) at Script24.run(Script24.groovy:1) at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:317) … 23 more

Are we missing any thing?

Please let us know.

Serhiy Onyshchenko commented on 16 March 2023

Hello, kalyan ,

Please, remove the sprint trigger and unexalate the sprint you’d attempted to sync - the sprint is going to be synced as part of the issue sync.

Syed Majid Hassan , I’m sure my explanation is cryptic, please help me.

Regards, Serhiy

Answer by Serhiy Onyshchenko on 06 June 2022

Hello, kalyan
Please, unexalate all the sprints - the approach I’m suggesting is syncing sprints as part of the issue sync.

Note, that since DevOps sprints don’t have status, updates to sprints seem less important to be synced.
Regards, Serhiy.