A Man With a Plan – Advanced Roadmaps Insights

The Problem

One of the challenges that I encountered this week was the need to include Advanced Roadmaps plans in a Jira DC to Cloud migration.  As you may be aware, JCMA gives you the option to either migrate ALL plans, or none of them.  There is no facility for selectively adding plans.  This is a problem because the client instance has 1200 Roadmaps plans, and trying to add that many plans to a migration causes JCMA to crash.

I set out this week to build the foundations of what I’m calling the Roadmaps Insight Tool.  The first version was intended to simply list every Roadmaps plan in an instance, and list each of its data sources (project, board, or filter). 

The resulting dataset is useful in a number of ways. First, it gives transparency to a part of Jira that is otherwise quite opaque.

Second, it indicates which data sources on each plan are invalid; typically this is because the referenced data source no longer exists.  A Jira administrator wanting to do a cleanup of the Roadmaps Plans could easily base that cleanup on this information.

Third, in the case of this particular client it allows us to see which Roadmaps plans can be deleted from the system.  This is only feasible because the client is migrating from a QA mirror instead of production, so any non-relevant data can be safely wiped away.

So I built a tool to return the information I needed.

The Tool

The tool is built primarily using Jira’s internal libraries, rather than making REST calls to the API.  Typically I would prefer to use the API directly, but making thousands of REST calls tends to choke a script.

One of the challenges in solving this problem is that the Advanced Roadmaps API is not documented at all. The most you’ll find is vague references to it on the Atlassian forums. However, with a bit of trial and error I found the endpoints I needed. 

API Endpoints

The tool first gathers a list of every Advanced Roadmaps Plan by sending a GET to /rest/jpo/1.0/programs/list.  This list of plans is comprehensive, though it only contains a few details about each plan. We’re after the ID of the plans. These are numeric, with indexing starting at 1. However, the list can easily contain gaps, if a plan was created and then deleted. For that reason we cannot simply find the highest plan ID# and iterate up to that; we’d be making calls to plans that no longer exist, and this is inefficient.  Instead we add each extant plan ID# to an array.

With an array of plan ID#s in hand, we iterate through the array and query /rest/jpo/1.0/plans/<id>.  Querying each ID returns a JSON blob that contains information about the data sources of each plan.  Here’s an example:

{
    "id": 3,
    "title": "Business Plan",
    "planningUnit": "Days",
    "hierarchyLevelToDefaultEstimateMap": {},
    "issueSources": [{
        "id": 3,
        "type": "Board",
        "value": "4"
    }, {
        "id": 4,
        "type": "Project",
        "value": "10200"
    }, {
        "id": 5,
        "type": "Filter",
        "value": "10402"
    }],
    "nonWorkingDays": [],
    "portfolioPlanVersion": 1,
    "includeCompletedIssuesFor": 30,
    "calculationConfiguration": {
        "ignoreSprints": false,
        "ignoreTeams": false,
        "ignoreReleases": false
    },
    "issueInferredDateSelection": 1,
    "rankAgainstStories": true,
    "baselineStartField": {
        "id": "baselineStart",
        "type": "BuiltIn",
        "key": "baselineStart"
    },
    "baselineEndField": {
        "id": "baselineEnd",
        "type": "BuiltIn",
        "key": "baselineEnd"
    },
    "createdTimestamp": 1670964732
}

Looking at the issueSources section, we see that this plan has one data source of each type. Therefor, we have all the information we need to return information about this plan, and make decisions.

Querying the Source IDs

One obvious challenge about the data in its current form is that the data sources are referenced by their ID, rather than their name. This is inconvenient, so we’ll need to address that too.  Please note that the examples below do not include the required imports, and are merely intended to illustrate the thinking.

Returning Project Names

Turning a project ID into a project name is the easiest of the three data sources to translate.   We need to simply create a project object using the project ID that we already have, and then get its name from the list of attributes:

def project = projectManager.getProjectObj(<ID>)

return project.getName()

Returning Filter Names

Turning a filter ID into a filter name isn’t much more complicated than turning a project ID into a project name.  We use the search request manager to search for the filter object by its ID, and then return its name.

def filter = searchRequestManager.getSearchRequestById(<ID>)

return filter.name

Returning Board Names

Of the three data sources, turning a board ID into a board name is perhaps the most complicated. Even still, it’s relatively simple.   We define the board ID, get the views of the current user, and search the views for that board ID.  Naturally this only works if the current user has administrative access, and therefor has access to view all of the boards.

def boardID = <ID>
def allViews = rapidViewService.getRapidViews(currentUser).value
def boardObj = allViews?.find {it.id == boardID}
 
return boardObj.name

Error Handling

Okay so we have everything we need to query each Plan for each type of data source. But what if the data source doesn’t exist?  Plans can easily reference data sources that have been deleted or recreated. For that reason, querying the data sources to turn an ID into a name needs to have error capturing, and a log statement within the Catch statement that indicates which Plan and data source caused the issue.

 

Full Code Example

We have the plans and their sources, we have the ability to turn source IDs into names, and we’re capturing any errors that occur.  With this in-hand, we have everything we need to gain some basic insight into Advanced Roadmaps.

import com.atlassian.greenhopper.model.rapid.BoardAdmin
import com.atlassian.greenhopper.service.rapid.view.BoardAdminService
import com.atlassian.greenhopper.service.rapid.view.RapidViewService
import com.atlassian.jira.component.ComponentAccessor
import com.onresolve.scriptrunner.runner.customisers.PluginModuleCompilationCustomiser
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.config.properties.APKeys
import com.atlassian.sal.api.net.Response
import com.atlassian.sal.api.net.ResponseException
import com.atlassian.sal.api.net.ReturningResponseHandler
import com.atlassian.sal.api.net.TrustedRequest
import com.atlassian.sal.api.net.TrustedRequestFactory
import com.atlassian.sal.api.net.Request
import groovy.json.JsonSlurper
import groovyx.net.http.ContentType
import groovyx.net.http.URIBuilder
import java.net.URL
import com.atlassian.jira.issue.search.*
import com.atlassian.jira.component.ComponentAccessor
import com.atlassian.jira.component.ComponentAccessor
 
def searchRequestManager = ComponentAccessor.getComponent(SearchRequestManager)
def rapidViewService = PluginModuleCompilationCustomiser.getGreenHopperBean(RapidViewService)
def boardAdminService = PluginModuleCompilationCustomiser.getGreenHopperBean(BoardAdminService)
def projectManager = ComponentAccessor.getProjectManager()
def currentUser = ComponentAccessor.jiraAuthenticationContext.loggedInUser
def baseUrl = ComponentAccessor.applicationProperties.getString(APKeys.JIRA_BASEURL)
def trustedRequestFactory = ComponentAccessor.getOSGiComponentInstanceOfType(TrustedRequestFactory)
def sb = []
def idBuffer = []
//Define a string buffer to hold the results.
//We need to add them to a string buffer because logging cuts off at 300 lines
 
def listOfPlansAPI = '/rest/jpo/1.0/programs/list'
//This URL gives us a list of plans in the instance
def url = baseUrl + listOfPlansAPI
def numberOfProjects = 0
 
//***********Get the IDs of all the Advanced Roadmaps Plans************
def request = trustedRequestFactory.createTrustedRequest(Request.MethodType.GET, url) as TrustedRequest
request.addTrustedTokenAuthentication(new URIBuilder(baseUrl).host, currentUser.name)
request.addHeader("Content-Type", ContentType.JSON.toString())
request.addHeader("X-Atlassian-Token", 'no-check')
//Authenticate the current user
 
def response = request.executeAndReturn(new ReturningResponseHandler < Response, Object > () {
  Object handle(Response response) throws ResponseException {
    if (response.statusCode != HttpURLConnection.HTTP_OK) {
      log.error "Received an error while posting to the rest api. StatusCode=$response.statusCode. Response Body: $response.responseBodyAsString"
      return null
    } else {
      def jsonResp = new JsonSlurper().parseText(response.responseBodyAsString)
      //log.info "REST API reports success: $jsonResp"
 
      jsonResp.plans.id.each{planID ->
 
          idBuffer.add(planID)
 
      }
 
    }
  }
})
//***********End Function***********
 
//***********Get the Data Sources of Advanced Roadmaps Plans************
idBuffer.each {
//Process each plan ID that was stored in the array
  planID ->
 
  def singlePlanAPI = '/rest/jpo/1.0/plans/' + planID
  //Plan details are accessed by hitting this API endpoint + the ID # of the plan
   
  def planUrl = baseUrl + singlePlanAPI
 
  def planRequest = trustedRequestFactory.createTrustedRequest(Request.MethodType.GET, planUrl) as TrustedRequest
  planRequest.addTrustedTokenAuthentication(new URIBuilder(planUrl).host, currentUser.name)
  planRequest.addHeader("Content-Type", ContentType.JSON.toString())
  planRequest.addHeader("X-Atlassian-Token", 'no-check')
 
  def planResponse = planRequest.executeAndReturn(new ReturningResponseHandler < Response, Object > () {
    Object handle(Response planResponse) throws ResponseException {
      if (planResponse.statusCode != HttpURLConnection.HTTP_OK) {
        log.error "Received an error while posting to the rest api. StatusCode=$planResponse.statusCode. Response Body: $planResponse.responseBodyAsString"
        return null
      } else {
        def jsonResp2 = new JsonSlurper().parseText(planResponse.responseBodyAsString)
        //log.info "REST API reports success: $jsonResp"
 
        jsonResp2.issueSources.each {source ->          
        //For each source of data in a given Plan
           
 
            if (source.type.toString() == "Project") {
            //If the source is a project, get the project name using the project ID
 
              try {
                def project = projectManager.getProjectObj(source.value.toInteger())
                //Get the project attributes as an object
 
                sb.add(jsonResp2.title + "(Plan ID#" + jsonResp2.id + "). " + " % " + "Project name: " + " % " + project.getName() + "<br>")
                //Add the details to the string buffer
                 
              } catch (E) {
                sb.add("Error: " + jsonResp2.title + "(Plan ID#" + jsonResp2.id + "). " + " " + "had an issue with project ID: " + ". Error: " + E.toString() + "<br>")
                //If the script encounters an error, log it to the buffer
              }
 
            } else if (source.type.toString() == "Board") {
             //If the source is a board, get the project name using the board ID
 
            try {
              //Use the board ID value to retrieve the board name
              def boardID = source.value.toInteger()
              def allViews = rapidViewService.getRapidViews(currentUser).value
              def rapidView = allViews?.find {
                it.id == boardID
              }
 
              sb.add(jsonResp2.title + "(Plan ID#" + jsonResp2.id + "). " + " % " + "Board name: " + " % " + rapidView.name + "<br>")
              //Add the details to the string buffer
 
             } catch (E) {
               //If the script encounters an error, log it to the buffer
             sb.add("Error: " + jsonResp2.title + "(Plan ID#" + jsonResp2.id + "). " + " " + "had an issue with board ID: " + source.value.toString() + ". Error: " + E.toString() + "<br>")
            }
 
          } else if (source.type.toString() == "Filter") {
            //If the source is a filter, get the project name using the filter ID
            try {
            def filter = searchRequestManager.getSearchRequestById(source.value.toLong())
            //Retrieve the filter object using the filter ID
              sb.add(jsonResp2.title + "(Plan ID#" + jsonResp2.id + "). " + " % " + "Filter name: " + " % " + filter.name + "<br>")
             //Add the details to the string buffer
 
 
            } catch (E) {
              sb.add("Error: " + jsonResp2.title + "(Plan ID#" + jsonResp2.id + "). " + " " + "had an issue with filter ID: " + source.value.toString() + ". Error: " + E.toString() + "<br>")
//If the script encounters an error, log it to the buffer              }
 
          }
 
        }
 
      }
    }
  })
}
//***********End Function***********
 
return sb
//Return the results  

 

 

 

Leave a Reply

Your email address will not be published. Required fields are marked *

One response

  1. Matt Doar Avatar
    Matt Doar

    Good idea!