Advanced Reporting Plugin

13 minute read

This tutorial provides instructions to create a Reporting Plugin called SampleJIRAReporting that has a single procedure called CollectReportingData, which sends user story data from a Jira instance to Devops Insight to enable metrics to be shown in RCC.

Prerequisites

These are assumptions for this tutorial.

  • /pdfgroovy/tutorialbasicreporting tutorial has been completed.

  • pdk is installed and setup.

  • An active CloudBees CD instance.

  • An active CloudBees CD DevOps Insight Center that is configured and connected to CloudBees CD instance.

  • Internet connection.

  • An accessible Jira Instance.

  • Write Access to a Git Repository.

Step 1 : Generate plugin from scratch

Create a plugin workspace with Plugin name as SampleJIRAReporting and Plugin type as reporting.

`pdk generate workspace`

Once done. the response from the command prompt and the workspace layout should look as follows:

Step 2 : Configuring plugin specifications and generating a plugin

Open the config/pluginspec.yaml file. Generated file will have the following content:

pluginInfo:
  # This is default sample specification
  # Feel free to change it
  # Call flowpdk showdoc pluginspec to see the list of available fields and their description
  pluginName: 'SampleJIRAPlugin'
  version: '1.0.0'
  description: 'Advanced - Reporting Tutorial Plugin'
  author: 'Sample Author'
  supportUrl: 'none'
  category: 'Utilities'
  shell: 'ec-groovy'
# The reporting configuration, will generate a procedure and a bunch of configuration scripts required for DOIS to work
devOpsInsight:
  supportedReports:
  -
    reportObjectType: 'build'
    parameters:
    -
      name: param1
      documentation: null
      required: true
      type: entry
      label: Sample Reporting Parameter
    -
      name: param2
      documentation: null
      required: false
      type: entry
      label: Sample Reporting Parameter 2
  # The name of the source as it will appear in the dashboards
  datasourceName: 'My Source Name'
  language: 'groovy'

# Plugin configuration description
configuration:
  # A set of fields will be added to process debug level in the configuration
  hasDebugLevel: true
  parameters:
  -
    name: config
    documentation: The name for the created configuration
    required: true
    type: entry
    label: Configuration Name
  -
    name: desc
    documentation: Description for the configuration
    required: false
    type: entry
    label: Description
  -
    name: endpoint
    documentation: Third-party endpoint to connect to.
    required: false
    type: entry
    label: Endpoint
  -
    name: credential
    documentation: A sample credential
    required: true
    type: credential
    label: Credential

Make the following changes to config/pluginspec.yaml:

  • Add jiraProjectName parameter to the procedure.

  • Add the configuration section restConfigInfo to enable check connection feature.

You can replace the content or check for the difference and implement them one by one:

pluginInfo:
  # This is default sample specification
  # Feel free to change it
  # Call flowpdk showdoc pluginspec to see the list of available fields and their description
  pluginName: 'SampleJIRAReporting'
  version: '1.0.0'
  description: 'Sample plugin for RCC <->Jira feature reporting'
  author: 'Sample Author'
  supportUrl: '<>'
  category: 'Utilities'
  shell: 'ec-groovy'

# The reporting configuration, will generate a procedure and a bunch of configuration scripts required for DOIS to work
devOpsInsight:
  supportedReports:
    -
      reportObjectType: 'feature'
      parameters:
        -
          name: jiraProjectName
          documentation: AJira project that will be used
          required: true
          type: entry
          label:Jira project name
    # The name of the source as it will appear in the dashboards
  datasourceName: 'JIRA Datasource from tutorial'
  language: 'groovy'

# Plugin configuration description
configuration:
  # A set of fields will be added to process debug level in the configuration
  hasDebugLevel: true
  restConfigInfo:
    endpointDescription: sample
    endpointLabel:Jira Server Endpoint
    checkConnectionUri: /rest/api/2/configuration
    headers: {Accept: "application/json"}
    # Auth schemes for the plugin
    authSchemes:
      basic:
        userNameLabel:Jira User Name
        passwordLabel:Jira User Password
        description: A username and password to connect to the Jira instance.
        checkConnectionUri: /rest/api/2/configuration
        credentialLabel:Jira Credentials

Run pdk generate plugin. The output will look something like this:

Step 3 : Writing reporting logic using pdk reporting component

Here’s the small overview of how the CollectReportingData works:

  • Read the metadata.

  • If no metadata was found, the reporting procedure should get initial amount of issues.

  • If metadata is found, get the last updated issue and check if we’ve already reported it.

  • If metadata is old, get issues updated after the last reported issue.

Each of the operation in bold has a boilerplate code that should be extended by the plugin developer.

Investigating autogenerated code

Let’s see what pdk has generated for the reporting plugin.

ReportingSampleJIRAReporting.groovy contains the following boilerplate code:

import com.cloudbees.flowpdf.*
import sun.reflect.generics.reflectiveObjects.NotImplementedException
import com.cloudbees.flowpdf.components.reporting.Dataset
import com.cloudbees.flowpdf.components.reporting.Metadata
import com.cloudbees.flowpdf.components.reporting.Reporting

/**
 * User implementation of the reporting classes
 */
class ReportingSampleJIRAPlugin extends Reporting {

    @Override
    int compareMetadata(Metadata param1, Metadata param2) {
        def value1 = param1.getValue()
        def value2 = param2.getValue()

        def pluginObject = this.getPluginObject()
        // Return 1 if there are newer records than record to which metadata is pointing.
        throw new NotImplementedException()
    }


    @Override
    List<Map<String, Object>> initialGetRecords(FlowPlugin flowPlugin, int i = 10) {
        def params = flowPlugin.getContext().getRuntimeParameters().getAsMap()
        throw new NotImplementedException()
        return records
    }

    @Override
    List<Map<String, Object>> getRecordsAfter(FlowPlugin flowPlugin, Metadata metadata) {
        def params = flowPlugin.getContext().getRuntimeParameters().getAsMap()
        def metadataValues = metadata.getValue()

        def log = flowPlugin.getLog()
        log.info("Got metadata value in getRecordsAfter:  ${metadataValues.toString()}")

        throw NotImplementedException()
        log.info("Records after GetRecordsAfter ${records.toString()}")
        return records
    }

    @Override
    Map<String, Object> getLastRecord(FlowPlugin flowPlugin) {
        def params = flowPlugin.getContext().getRuntimeParameters().getAsMap()
        def log = flowPlugin.getLog()
        log.info("Last record runtime params: ${params.toString()}")
        throw new NotImplementedException()
    }

    @Override
    Dataset buildDataset(FlowPlugin plugin, List<Map> records) {
        def dataset = this.newDataset(['your report object type'], [])
        def context = plugin.getContext()
        def params = context.getRuntimeParameters().getAsMap()

        def log = plugin.getLog()
        log.info("Start procedure buildDataset")

        log.info("buildDataset received params: ${params}")
        throw new NotImplementedException()
        return dataset
    }
}

And the content of generated SampleJIRAPlugin.groovy contains:

import sun.reflect.generics.reflectiveObjects.NotImplementedException
import com.cloudbees.flowpdf.components.ComponentManager

import com.cloudbees.flowpdf.*

/**
* SampleJIRAPlugin
*/
class SampleJIRAPlugin extends FlowPlugin {

    @Override
    Map<String, Object> pluginInfo() {
        return [
                pluginName     : '@PLUGIN_KEY@',
                pluginVersion  : '@PLUGIN_VERSION@',
                configFields   : ['config'],
                configLocations: ['ec_plugin_cfgs'],
                defaultConfigValues: [:]
        ]
    }

/**
    * Procedure parameters:
    * @param config
    * @param jiraProjectName
    * @param previewMode
    * @param transformScript
    * @param debug
    * @param releaseName
    * @param releaseProjectName

    */
    def collectReportingData(StepParameters paramsStep, StepResult sr) {
        def params = paramsStep.getAsMap()

        throw new NotImplementedException()

        if (params['debug']) {
            log.setLogLevel(log.LOG_DEBUG)
        }


        Reporting reporting = (Reporting) ComponentManager.loadComponent(ReportingSampleJIRAPlugin.class, [
                reportObjectTypes  : ['feature'],
                metadataUniqueKey  : 'fill me in',
                payloadKeys        : ['fill me in'],
        ], this)
        reporting.collectReportingData()

    }
// === step ends ===

}

Notice that at line #43, the reporting component gets loaded with three special parameters, and the reportObjectTypes key is already set to feature. We need to fill these two:

  • metadataUniqueKey

  • payloadKeys

Configuration of the reporting component

Let’s check into meaning of each one closely:

  • metadataUniqueKey

    Metadata facilitates Change Data Capture where in the plugin is aware if there is any new change that would entail sending reporting data to the DevOps Insight Data Server. The fields that participate in Metadata essentially provide the mechanism to capture if that data has changed. Note that this mechanism is required as the plugin is using the PULL model, where in it is periodically looking for what has changed. Since the key has to be unique for the project and query, and the only variable in the query is project, we choose it to be the Jira project name from the data source configuration.

    metadataUniqueKey: params['jiraProjectName']
  • payloadKeys

    To perform check of the local and remote states for the synchronization we should be able to compare them. Usually, we would like to know if the remote system has new updates. For this we will use "Updated" field of The Jira issue, which is reported as a 'modifiedOn' attribute of the DevOps Insight report object (Note: we will check into report object attributes later). The payloadKeys array of strings contains the values we want to store in the metadata. To have an additional information, we will also include a key of the last reported issue, which is reported as a key.

    payloadKeys      : ['key', 'modifiedOn']

Replace the collectReportingData() step method in SampleJIRAReporting.groovy with the following:

def collectReportingData(StepParameters paramsStep, StepResult sr) {
    def params = paramsStep.getAsMap()

    if (params['debug']) {
        log.setLogLevel(log.LOG_DEBUG)
    }

    Reporting reporting = (Reporting) ComponentManager.loadComponent(ReportingSampleJIRAReporting.class, [
            reportObjectTypes: ['feature'],
            metadataUniqueKey: params['jiraProjectName'],
            payloadKeys      : ['key', 'modifiedOn']
    ] as Map<String, Object>, this)

    reporting.collectReportingData()
}

Receiving issues from the Jira server

As we will use JQL to get information from Jira, ensure that the following JQL returns a list of issues.

project = "TEST"

To connect our plugin with the reporting system (Jira) we have to define a process of pulling the freshest issues in a correct order with a limit. To get issues we will use GET search API endpoint with jql and maxResults parameters.

Let’s implement function to perform the GET request. You can copy the following code to your SampleJIRAReporting.groovy.

def get(String path, Map<String, String> queryParams) {
    Context context = getContext()
    REST rest = context.newRESTClient()
    return rest.request('GET', '/rest/api/2/' + path, queryParams)
}

REST instance will get the values from the configuration, apply the authorization and encode the result.

Let’s implement the next method: searching for issues with limiting count of the results:

def getIssues(String projectName, Map<String, String> opts) {
    def requestParams = [jql: "project=$projectName AND issuetype=Story ORDER BY updatedDate ASC"]
    if (opts['limit']) {
        requestParams['maxResults'] = opts['limit']
    }
    def issues = get('search', requestParams)
    return issues['issues']
}

And the method that will return the issue that was updated last:

def getLastUpdatedIssue(String projectName) {
    String lastJql = "project=$projectName AND issuetype=Story ORDER BY updatedDate DESC"
    def result = get('search', [jql: lastJql, maxResults: '1'])
    if (result['total'] > 0 && result.issues.size()) {
        return result['issues'][0]
    }
    throw new UnexpectedMissingValue("JIRA does not return the last updated issue")
}

If our reporting component discovers fresh issues, we search for issues that were updated after last synchronization. Before we proceed, note that DevOps Insight server has date format in ISO8601 with a UTC timezone. For example:

2019-01-01T14:38:01.000Z

The Jira server uses different format for records and another for JQL. Here’re two simple methods that will convert Jira datetime string to ISO8601 and the ISO8601 to a format that JQL accepts. Add the following code to your SampleJIRAReporting.groovy:

String jiraDatetimeToISODatetime(String rawDate) {
    if (rawDate == null || !rawDate) return ''

    SimpleDateFormat jiraDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
    jiraDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))
    Date parsedDate = jiraDateFormat.parse(rawDate)

    SimpleDateFormat devopsDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
    devopsDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))

    String formatted = devopsDateFormat.format(parsedDate)

    return formatted
}

String isoDatetimeToJqlDatetime(String isoDate) {
    SimpleDateFormat devopsDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
    devopsDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))
    Date parsedDate = devopsDateFormat.parse(isoDate)

    SimpleDateFormat jqlDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm")
    jqlDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))

    // The timezone here should be adjusted to Jira value
    String jiraFormattedDate = jqlDateFormat.format(parsedDate)

    return jiraFormattedDate
}

Now we can implement method that perform JQL search for issues updated after the last reported issue. Add the following code to your SampleJIRAReporting.groovy:

def getIssuesAfterDate(String projectName, String lastUpdateDateISO) {
    String jiraFormattedDate = isoDatetimeToJqlDatetime(lastUpdateDateISO)
    String storyJql = "project='$projectName' AND issuetype=Story AND updatedDate >= \"${jiraFormattedDate}\" ORDER BY updatedDate DESC"

    def result = get('search', [jql: storyJql])
    if (result['total'] > 0 && result.issues.size()) {
        return result['issues']
    }

    throw new UnexpectedMissingValue("JIRA did not return issue updated after ${jiraFormattedDate}. Check the timezone.")
}

Implementing the reporting component

Now we will edit ReportingSampleJIRAReporting.groovy file. Make sure that it has the following imports:

import com.cloudbees.flowpdf.FlowPlugin
import com.cloudbees.flowpdf.components.reporting.Dataset
import com.cloudbees.flowpdf.components.reporting.Metadata
import com.cloudbees.flowpdf.components.reporting.Reporting

import net.sf.json.JSONObject
import java.text.SimpleDateFormat

Let’s implement the reporting methods one by one:

compareMetadata

The purpose of the compareMetadata method is to tell the reporting component if one issue has been updated later than another.

This method is used in two cases:

  • Reporting compares stored metadata with a metadata built from last modified issue.

  • Reporting sorts the payloads before sending it to the DevOps Insight Server.

The method receives two metadata instances. The Metadata.getValue() returns object that represents stored value. As we have supplied key and modifiedOn as the payloadKeys, metadata value will have a following structure:

{"key":"TEST-26","modifiedOn":"2020-02-05T13:43:08.000Z"}
The value of 'modifiedOn' contains date in DevOps Insight format (ISO8601 in the UTC timezone).

If the last updated Jira has modifiedOn time in the future, Reporting should send new changes to the DevOps Insight Server. Replace the method compareMetadata() in ReportingSampleJIRAReporting.groovy by the following code:

@Override
int compareMetadata(Metadata param1, Metadata param2) {
    SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
    format.setTimeZone(TimeZone.getTimeZone("UTC"))

    Date date1 = format.parse(param1.getValue()['modifiedOn'])
    Date date2 = format.parse(param2.getValue()['modifiedOn'])

    // Return 1 if there are newer records than record to which metadata is pointing.
    return date2.compareTo(date1)
}

initialGetRecords

This method is called when there is no metadata stored for the context. We will use our SampleJIRAReporting.getIssues method to retrieve issues. Replace the method initialGetRecords() in ReportingSampleJIRAReporting.groovy by the following code:

@Override
List<Map<String, Object>> initialGetRecords(FlowPlugin flowPlugin, int i = 10) {
    def params = flowPlugin.getContext().getRuntimeParameters().getAsMap()
    return flowPlugin.getIssues(params['jiraProjectName'], [limit: i])
}

To prevent loading too much information, limit should be supplied. It can be changed in the schedule parameters or in the Reporting component initialization values.

If you want to change the limit to a huge amount, you should be aware that Jira returns search results in chunks if the number of the results is greater than the count specified in the Jira configuration. Look to the source code of SampleJIRAOAuth for implementation of the chunked response retrieval.

getLastRecord

This method will be called when we want to check if the state of the reporting system has changed and should return the last updated issue. We have implemented it in the plugin code (SampleJIRAReporting.groovy) so here method will look very simple:

Replace the method getLastRecord() in ReportingSampleJIRAReporting.groovy by the following code:

@Override
Map<String, Object> getLastRecord(FlowPlugin flowPlugin) {
    def params = flowPlugin.getContext().getRuntimeParameters().getAsMap()
    return flowPlugin.getLastUpdatedIssue(params['jiraProjectName'])
}

getRecordsAfter

When we know that there are updated issues in the reporting system, we want to grab only new ones. Modified date will be given in Metadata instance.

Replace the method getRecordsAfter() in ReportingSampleJIRAReporting.groovy by the following code:

@Override
List<Map<String, Object>> getRecordsAfter(FlowPlugin flowPlugin, Metadata metadata) {
    def params = flowPlugin.getContext().getRuntimeParameters().getAsMap()
    def metadataValue = metadata.getValue()
    return flowPlugin.getIssuesAfterDate(params['jiraProjectName'], metadataValue['modifiedOn'])
}

buildDataset

Now, the last piece. We should tell the Reporting component how our Jira issues should be transformed into the DevOps Insight Server payload.

First, we should know how the payload should look like. Go to the DevOps Insight user guide to see the list of fields for the feature report. Additionally, you can use the following call to get the report attributes that are defined on CloudBees CD server.

ectool --format json getReportObjectAttributes feature
* There are specific requirements for fields featuretype, resolution and status. They should contain one of the predefined values. As Jira can be tuned by the user to contain additional statuses and resolutions or they can differ between The Jira releases, we should map the original value to one that is expected by the DevOps Insight Server.

+ *Some of the fields, like pluginConfiguration, pluginName, releaseName and releaseProjectName are well-known and will be supplied by the Reporting component if omitted.

Replace the method buildDataset() in ReportingSampleJIRAReporting.groovy by the following code:

@Override
Dataset buildDataset(FlowPlugin plugin, List<Map> records) {
    SampleJIRAReporting jiraPlugin = plugin as SampleJIRAReporting
    Dataset dataset = this.newDataset(['feature'], [])

    Context context = plugin.getContext()
    Map params = context.getRuntimeParameters().getAsMap()

    for (Map<String, Object> issue : records) {
        plugin.log.debug("Issue:", JSONObject.fromObject(issue).toString())

        Map<String, String> statusMappings = [
                'Done'       : 'Closed',
                'Closed'     : 'Closed',
                'In Progress': 'In Progress',
                'Open'       : 'Open',
                'To Do'      : 'Open',
                'Reopened'   : 'Reopened',
                'Resolved'   : 'Resolved',
        ]

        Map<String, String> resolutionMappings = [
                'Cannot Reproduce': 'Cannot Reproduce',
                'Duplicate'       : 'Duplicate',
                'Done'            : 'Fixed',
                'Won\'t Do'       : 'Incomplete',
                'Won\'t Do'       : 'Won\'t Fix'
        ]


        String rawStatus = issue.fields.status?.statusCategory?.name ?: ''
        String rawResolution = issue.fields.resolution?.name ?: ''

        String jiraModifiedOn = issue.fields.updated ?: ''
        String jiraCreatedOn = issue.fields.created ?: ''
        String jiraResolvedOn = issue.fields.resolutionDate ?: ''

        String modifiedOn = jiraPlugin.jiraDatetimeToISODatetime(jiraModifiedOn)
        String createdOn = jiraPlugin.jiraDatetimeToISODatetime(jiraCreatedOn)
        String resolvedOn = jiraPlugin.jiraDatetimeToISODatetime(jiraResolvedOn)

        dataset.newData([
                reportObjectType: 'feature',
                values: [
                        source     : 'JIRA',
                        sourceUrl  : params['endpoint'],
                        type       : issue.fields.issuetype?.name,
                        featureName: issue.fields.summary,
                        storyPoints: issue.fields.storyPoints ?: '',
                        key        : issue.key,
                        resolution : resolutionMappings[rawResolution] ?: '',
                        status     : statusMappings[rawStatus] ?: 'Open',
                        modifiedOn : modifiedOn,
                        createdOn  : createdOn,
                        resolvedOn : resolvedOn,
                ]])
    }

    return dataset
}

Step 4 : Review the resulting code

After all the modifications, this is what SampleJIRAReporting.groovy should contain:

import com.cloudbees.flowpdf.*
import com.cloudbees.flowpdf.client.REST
import com.cloudbees.flowpdf.components.ComponentManager
import com.cloudbees.flowpdf.components.reporting.Reporting
import com.cloudbees.flowpdf.exceptions.UnexpectedMissingValue

import java.text.SimpleDateFormat

/**
 * SampleJIRAReporting
 */
class SampleJIRAReporting extends FlowPlugin {

    @Override
    Map<String, Object> pluginInfo() {
        return [
                pluginName         : '@PLUGIN_KEY@',
                pluginVersion      : '@PLUGIN_VERSION@',
                configFields       : ['config'],
                configLocations    : ['ec_plugin_cfgs'],
                defaultConfigValues: [authScheme: 'basic']
        ]
    }

/**
 * Procedure parameters:
 * @param config
 * @param jiraProjectName
 * @param previewMode
 * @param transformScript
 * @param debug
 * @param releaseName
 * @param releaseProjectName

 */
    def collectReportingData(StepParameters paramsStep, StepResult sr) {
        def params = paramsStep.getAsMap()

        if (params['debug']) {
            log.setLogLevel(log.LOG_DEBUG)
        }

        Reporting reporting = (Reporting) ComponentManager.loadComponent(ReportingSampleJIRAReporting.class, [
                reportObjectTypes: ['feature'],
                metadataUniqueKey: params['jiraProjectName'],
                payloadKeys      : ['key', 'modifiedOn']
        ] as Map<String, Object>, this)

        reporting.collectReportingData()
    }
// === step ends ===

    def get(String path, Map<String, String> queryParams) {
        Context context = getContext()
        REST rest = context.newRESTClient()
        return rest.request('GET', '/rest/api/2/' + path, queryParams)
    }

    def getIssues(String projectName, Map<String, String> opts) {
        def requestParams = [jql: "project=$projectName AND issuetype=Story ORDER BY updatedDate ASC"]
        if (opts['limit']) {
            requestParams['maxResults'] = opts['limit']
        }
        def issues = get('search', requestParams)
        return issues['issues']
    }

    def getLastUpdatedIssue(String projectName) {
        String lastJql = "project=$projectName AND issuetype=Story ORDER BY updatedDate DESC"
        def result = get('search', [jql: lastJql, maxResults: '1'])
        if (result['total'] > 0 && result.issues.size()) {
            return result['issues'][0]
        }
        throw new UnexpectedMissingValue("JIRA does not returned the last updated issue")
    }

    String jiraDatetimeToISODatetime(String rawDate) {
        if (rawDate == null || !rawDate) return ''

        SimpleDateFormat jiraDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
        jiraDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))
        Date parsedDate = jiraDateFormat.parse(rawDate)

        SimpleDateFormat devopsDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
        devopsDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))

        String formatted = devopsDateFormat.format(parsedDate)

        return formatted
    }

    String isoDatetimeToJqlDatetime(String isoDate) {
        SimpleDateFormat devopsDateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
        devopsDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))
        Date parsedDate = devopsDateFormat.parse(isoDate)

        SimpleDateFormat jqlDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm")
        jqlDateFormat.setTimeZone(TimeZone.getTimeZone("UTC"))

        // The timezone here should be adjusted to Jira value
        String jiraFormattedDate = jqlDateFormat.format(parsedDate)

        return jiraFormattedDate
    }

    def getIssuesAfterDate(String projectName, String lastUpdateDateISO) {
        String jiraFormattedDate = isoDatetimeToJqlDatetime(lastUpdateDateISO)
        String storyJql = "project='$projectName' AND issuetype=Story AND updatedDate >= \"${jiraFormattedDate}\" ORDER BY updatedDate DESC"

        def result = get('search', [jql: storyJql])
        if (result['total'] > 0 && result.issues.size()) {
            return result['issues']
        }

        throw new UnexpectedMissingValue("JIRA did not return issue updated after ${jiraFormattedDate}. Check the timezone.")
    }
}

And this is content of ReportingSampleJIRAReporting.groovy:

import com.cloudbees.flowpdf.FlowPlugin
import com.cloudbees.flowpdf.components.reporting.Dataset
import com.cloudbees.flowpdf.components.reporting.Metadata
import com.cloudbees.flowpdf.components.reporting.Reporting

import net.sf.json.JSONObject
import java.text.SimpleDateFormat

/**
 * User implementation of the reporting classes
 */
class ReportingSampleJIRAReporting extends Reporting {

    @Override
    int compareMetadata(Metadata param1, Metadata param2) {
        SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'")
        format.setTimeZone(TimeZone.getTimeZone("UTC"))

        Date date1 = format.parse(param1.getValue()['modifiedOn'])
        Date date2 = format.parse(param2.getValue()['modifiedOn'])

        // Return 1 if there are newer records than record to which metadata is pointing.
        return date2.compareTo(date1)
    }

    @Override
    List<Map<String, Object>> initialGetRecords(FlowPlugin flowPlugin, int i = 10) {
        def params = flowPlugin.getContext().getRuntimeParameters().getAsMap()
        return flowPlugin.getIssues(params['jiraProjectName'], [limit: i])
    }

    @Override
    Map<String, Object> getLastRecord(FlowPlugin flowPlugin) {
        def params = flowPlugin.getContext().getRuntimeParameters().getAsMap()
        return flowPlugin.getLastUpdatedIssue(params['jiraProjectName'])
    }

    @Override
    List<Map<String, Object>> getRecordsAfter(FlowPlugin flowPlugin, Metadata metadata) {
        def params = flowPlugin.getContext().getRuntimeParameters().getAsMap()
        def metadataValue = metadata.getValue()
        return flowPlugin.getIssuesAfterDate(params['jiraProjectName'], metadataValue['modifiedOn'])
    }

    @Override
    Dataset buildDataset(FlowPlugin plugin, List<Map> records) {
        SampleJIRAReporting jiraPlugin = plugin as SampleJIRAReporting
        Dataset dataset = this.newDataset(['feature'], [])

        Context context = plugin.getContext()
        Map params = context.getRuntimeParameters().getAsMap()

        for (Map<String, Object> issue : records) {
            plugin.log.debug("Issue:", JSONObject.fromObject(issue).toString())

            Map<String, String> statusMappings = [
                    'Done'       : 'Closed',
                    'Closed'     : 'Closed',
                    'In Progress': 'In Progress',
                    'Open'       : 'Open',
                    'To Do'      : 'Open',
                    'Reopened'   : 'Reopened',
                    'Resolved'   : 'Resolved',
            ]

            Map<String, String> resolutionMappings = [
                    'Cannot Reproduce': 'Cannot Reproduce',
                    'Duplicate'       : 'Duplicate',
                    'Done'            : 'Fixed',
                    'Won\'t Do'       : 'Incomplete',
                    'Won\'t Do'       : 'Won\'t Fix'
            ]


            String rawStatus = issue.fields.status?.statusCategory?.name ?: ''
            String rawResolution = issue.fields.resolution?.name ?: ''

            String jiraModifiedOn = issue.fields.updated ?: ''
            String jiraCreatedOn = issue.fields.created ?: ''
            String jiraResolvedOn = issue.fields.resolutionDate ?: ''

            String modifiedOn = jiraPlugin.jiraDatetimeToISODatetime(jiraModifiedOn)
            String createdOn = jiraPlugin.jiraDatetimeToISODatetime(jiraCreatedOn)
            String resolvedOn = jiraPlugin.jiraDatetimeToISODatetime(jiraResolvedOn)

            dataset.newData([
                    reportObjectType: 'feature',
                    values: [
                            source     : 'JIRA',
                            sourceUrl  : params['endpoint'],
                            type       : issue.fields.issuetype?.name,
                            featureName: issue.fields.summary,
                            storyPoints: issue.fields.storyPoints ?: '',
                            key        : issue.key,
                            resolution : resolutionMappings[rawResolution] ?: '',
                            status     : statusMappings[rawStatus] ?: 'Open',
                            modifiedOn : modifiedOn,
                            createdOn  : createdOn,
                            resolvedOn : resolvedOn,
                    ]])
        }

        return dataset
    }
}

Step 5 : DOIS setup and reporting check

Install and promote the plugin. Then navigate to releases and create Jira Test Project release as follows:

Navigate to DevOps Insight Center:

Navigate to Release Command Center:

Select the Dashboard editor:

Select Setup:

Select Add button for User stories section:

Select our tutorial plugin, fill in the Jira project name parameter and click for new configuration creation:

Enter valid config values:

Your Jira endpoint should contain an URL of the Jira instance you have access to.

Go to Platform Home Page Projects, choose your project and select Schedules.

Run the schedule to immediately collect the issues.

Navigate to Release Command Center again to see your Release data being populated.

Your reporting numbers may be different.

The Jira instance used for this tutorial has a Story Points field as a custom field. Custom fields are not covered for the simplicity.