Last updatedMay 2, 2018

Creating a Jira report

Applicable:

This tutorial applies to Jira 7.1.0 and later.

Level of experience:

This is an advanced tutorial. You should have completed at least one intermediate tutorial before working through this tutorial. See the list of tutorials in DAC.

Time estimate:

It should take you approximately 1 hour to complete this tutorial.

Overview of the tutorial

This tutorial shows you how to create custom Jira reports. In this tutorial, you'll add two reports:

  1. Single Level Group By Report Extended.
  2. Creation Report.

The Single Level Group By Report Extended builds on an existing report in Jira. The existing report looks like this.

When you're done, you have a new report that looks like this.

Notice that Assignee and Updated fields appear in the output.

The Creation Report displays a histogram of issues created over a specified time and broken into certain intervals of time.

Your completed app will consist of the following components:

  1. Java classes encapsulating the app logic.
  2. Resources for display of the app user interface (UI).
  3. An app descriptor to present the app UI in Jira.

When you finish, all these components will be packaged in a single JAR file.

About these instructions

You can use any supported combination of operating system and IDE to create this app. These instructions were written using IntelliJ IDEA 2017.3 on macOS Sierra. If you use another operating system or IDE combination, you should use the equivalent operations for your specific environment.

This tutorial was last tested with Jira 7.7.1 using the Atlassian SDK 6.3.10.

Before you begin

To complete this tutorial, you need to know the following: 

  1. The basics of Java development: classes, interfaces, methods, how to use the compiler, and so on.
  2. How to create an Atlassian plugin project using the Atlassian Plugin SDK.
  3. The basics of using and administering Jira.
  4. This tutorial also involves creating Apache Velocity templates. To extend the tutorial code, you should have a good handle on how Velocity templates work.

App source

We encourage you to work through this tutorial. If you want to skip ahead or check your work when you are done, you can find the app source code on Atlassian Bitbucket.

To clone the repository, run the following command:

1
git clone https://atlassian_tutorial@bitbucket.org/atlassian_tutorial/jira-report-plugin.git

Alternatively, you can download the source using the Downloads page here: bitbucket.org/atlassian_tutorial/jira-report-plugin

Step 1. Create the app project

In this step, you'll use the Atlassian Plugin SDK to generate the scaffolding for your app project. The Atlassian Plugin SDK automates much of the work of app development for you. It includes commands for creating an app and adding modules to the app.

  1. Set up the Atlassian Plugin SDK and build a project if you have not done it yet.
  2. Navigate to the directory where you want to keep the app project and run the following SDK command:

    1
    atlas-create-jira-plugin
  3. To identify your app, enter the following information.

    group-id

    com.atlassian.plugins.tutorial.jira

    artifact-id

    jira-report-plugin

    version

    1.0-SNAPSHOT

    package

    com.atlassian.plugins.tutorial.jira

  4. Confirm your entries when prompted.

    The SDK finishes up and generates a directory for you with the initial project files, including a POM (Project Object Model definition file), stub source code, and resources.

  5. Navigate to the directory created by SDK.

    1
    cd jira-report-plugin
  6. Delete the test directories.

    Setting up testing for your app isn't part of this tutorial. Run the following commands to delete the generated test skeleton:

    1
    2
    rm -rf ./src/test/java
    rm -rf ./src/test/resources/
  7. Delete the unneeded Java class files.

    1
    rm -rf ./src/main/java/com/atlassian/plugins/tutorial/jira/*

Step 2. Review and tweak the generated stub code

It's a good idea to familiarize yourself with the project configuration file (that is pom.xml) and resource files. In this section, you will review and tweak the pom.xml file and the app descriptor file.

Open your app project in your favorite IDE and follow instructions in the next sections.

Add app metadata to the POM

The POM is located at the root of your project and declares the project dependencies and other information. In this step you add metadata about your app and your company or organization to the file.

  1. In the root folder of your app, open the pom.xml file.
  2. Add your company or organization name and website URL to the organization element.

    1
    2
    3
    4
    <organization>
        <name>Example Company</name>
        <url>http://www.example.com/</url>
    </organization>
  3. Update the project description element as follows:

    1
    <description>Extends Jira issue reports.</description>
  4. Remove the commenting from around the dependency element for the jira-core artifact, this will include dependency in your project. This tutorial extends an existing Jira report that relies on APIs in the Jira core package. So, while you normally won't need to do this, you do for this tutorial.

  5. Save and close the file.

Review the generated app descriptor

Your stub code contains an app descriptor file atlassian-plugin.xml. This is an XML file that identifies the app to the host application (Jira) and defines the required app functionality.

  1. Under the src/main/resources directory of your project home, open the descriptor file.

    You should see something like this (comments removed):  

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<atlassian-plugin key="${atlassian.plugin.key}" name="${project.name}" plugins-version="2">
    <plugin-info>
        <description>${project.description}</description>
        <version>${project.version}</version>
        <vendor name="${project.organization.name}" url="${project.organization.url}" />
        <param name="plugin-icon">images/pluginIcon.png</param>
        <param name="plugin-logo">images/pluginLogo.png</param>
    </plugin-info>
    <resource type="i18n" name="i18n" location="jira-report-plugin"/>
    <web-resource key="jira-report-plugin-resources" name="jira-report-plugin Web Resources">
        <dependency>com.atlassian.auiplugin:ajs</dependency>
        <resource type="download" name="jira-report-plugin.css" location="/css/jira-report-plugin.css"/>
        <resource type="download" name="jira-report-plugin.js" location="/js/jira-report-plugin.js"/>
        <resource type="download" name="images/" location="/images"/>
        <context>jira-report-plugin</context>
    </web-resource>
</atlassian-plugin>

Step 3. Add the plugin modules

Now you will use the plugin module generator (another atlas command) to generate the stub code for modules for the app. For your modules, add two report modules as follows:

  1. Open a Terminal and navigate to the app root folder where the pom.xml is located.
  2. Run atlas-create-jira-plugin-module command.
  3. Enter the number for the Report module.
  4. When prompted, enter the following.

    Enter New Classname

    SingleLevelGroupByReportExtended

    Package Name

    com.atlassian.plugins.tutorial.jira.reports

  5. Select N for Show Advanced Setup.

  6. Select Y for Add Another Plugin Module.
  7. Enter the number for the Report module again.
  8. Enter the following.

    Enter New Classname

    CreationReport

    Package Name

    com.atlassian.plugins.tutorial.jira.reports

  9. Select N for Show Advanced Setup.

  10. Select N for Add Another Plugin Module.
  11. Confirm your selections.

The SDK generates the code files for the modules and adds them to the app descriptor. It also adds other resources, such as Velocity files and i18n resource files.

Step 4. Add module properties

Report module properties are the configurable fields that the app exposes in the Jira UI. We'll add a few properties to the module definition.

  1. Navigate to src/main/resources and open the atlassian-plugin.xml file.

  2. Under the Single Level Group By Report Extended module, uncomment the properties element and replace the default property elements it contains with the following: 

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    ``` xml
    <report name="Single Level Group By Report Extended"...
       ...
       <properties>
             <property>
                 <key>filterid</key>
                 <name>report.singlelevelgroupby.filterId</name>
                 <description>report.singlelevelgroupby.filterId.description</description>
                 <type>filterpicker</type>
                 <i18n>false</i18n>
             </property>
             <property>
                 <key>mapper</key>
                 <name>report.singlelevelgroupby.mapper</name>
                 <description>report.singlelevelgroupby.mapper.description</description>
                 <type>select</type>
                 <values class="com.atlassian.jira.issue.statistics.FilterStatisticsValuesGenerator" />
             </property>
        </properties>
    ```
    
    We're adding two new properties: a filter picker and a selector for the statistics type by which to group the result.
  3. Under the Creation Report module, uncomment and replace the properties element with the following:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    <report name="Creation Report"...
       ...
       <properties>
             <property>
                 <key>projectid</key>
                 <name>report.issuecreation.projectid.name</name>
                 <description>report.issuecreation.projectid.description</description>
                 <type>filterprojectpicker</type>
             </property>
             <property>
                 <key>startDate</key>
                 <name>report.issuecreation.startdate</name>
                 <description>report.issuecreation.startdate.description</description>
                 <type>date</type>
             </property>
             <property>
                 <key>endDate</key>
                 <name>report.issuecreation.enddate</name>
                 <description>report.issuecreation.enddate.description</description>
                 <type>date</type>
             </property>
             <property>
                 <key>interval</key>
                 <name>report.issuecreation.interval</name>
                 <description>report.issuecreation.interval.description</description>
                 <type>long</type>
                 <default>3</default>
             </property>
       </properties>    

    Here we've added the following:

    1. projectid — allows users to choose the project or filter used to generate the report. The projects that are available in a given Jira instance are retrieved through Jira filterprojectpicker facility.
    2. startDate — sets the start of the time period included in the report.
    3. endDate — sets the end of the time period in the report.
    4. interval — specifies the time interval used to divide the overall time period. In other words, this is the histogram interval.

So far we've been working on two modules at once, each of them corresponds to separate reports in Jira. Now let's take them one at a time, starting with the Single Level Group By Report Extended.

Step 5. Write the Single Level Group By Report Extended code

When you used the SDK to create the modules, it gave you the stub code files for the reports. The stub code is very simple: just a constructor and a few imports. We'll build on it now.

For our first report, we extend a report delivered with Jira, the SingleLevelGroupByReport class. If you have an access to the Jira source code, you can find the source code for the original at this location:

1
jira-components/jira-core/src/main/java/com/atlassian/jira/plugin/report/impl/SingleLevelGroupByReport.java

Our goal is to include the time of last update for each issue in the report output. It should be rendered in the appropriate date and time format configured in Jira.

The view template gets values to display in Jira using the parameter map that is passed by the module code. So, to add the DateTimeFormatter object to the Velocity template, we'll modify the parameter map generated by the original Jira report .

  1. Under the project home at src/main/java/com/atlassian/plugins/tutorial/jira/reports, open the SingleLevelGroupByReportExtended.java file.

  2. Replace its contents with the following:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165
    package com.atlassian.plugins.tutorial.jira.reports;
    
    import com.atlassian.jira.bc.JiraServiceContext;
    import com.atlassian.jira.bc.JiraServiceContextImpl;
    import com.atlassian.jira.bc.filter.SearchRequestService;
    import com.atlassian.jira.bc.issue.search.SearchService;
    import com.atlassian.jira.datetime.DateTimeFormatter;
    import com.atlassian.jira.datetime.DateTimeFormatterFactory;
    import com.atlassian.jira.datetime.DateTimeStyle;
    import com.atlassian.jira.exception.PermissionException;
    import com.atlassian.jira.issue.CustomFieldManager;
    import com.atlassian.jira.issue.IssueFactory;
    import com.atlassian.jira.issue.fields.FieldManager;
    import com.atlassian.jira.issue.index.IssueIndexManager;
    import com.atlassian.jira.issue.search.ReaderCache;
    import com.atlassian.jira.issue.search.SearchException;
    import com.atlassian.jira.issue.search.SearchProvider;
    import com.atlassian.jira.issue.search.SearchRequest;
    import com.atlassian.jira.issue.statistics.FilterStatisticsValuesGenerator;
    import com.atlassian.jira.issue.statistics.StatisticsMapper;
    import com.atlassian.jira.issue.statistics.StatsGroup;
    import com.atlassian.jira.issue.statistics.util.OneDimensionalDocIssueHitCollector;
    import com.atlassian.jira.plugin.report.impl.AbstractReport;
    import com.atlassian.jira.project.ProjectManager;
    import com.atlassian.jira.user.ApplicationUser;
    import com.atlassian.jira.util.SimpleErrorCollection;
    import com.atlassian.jira.web.FieldVisibilityManager;
    import com.atlassian.jira.web.action.ProjectActionSupport;
    import com.atlassian.jira.web.bean.PagerFilter;
    import com.atlassian.plugin.spring.scanner.annotation.component.Scanned;
    import com.atlassian.plugin.spring.scanner.annotation.imports.JiraImport;
    import com.atlassian.util.profiling.UtilTimerStack;
    import com.google.common.collect.ImmutableMap;
    import com.opensymphony.util.TextUtils;
    import org.apache.commons.lang.StringUtils;
    import org.apache.log4j.Logger;
    import org.apache.lucene.search.Collector;
    
    import java.util.Arrays;
    import java.util.Map;
    
    @Scanned
    public class SingleLevelGroupByReportExtended extends AbstractReport {
        private static final Logger log = Logger.getLogger(SingleLevelGroupByReportExtended.class);
    
        @JiraImport
        private final SearchProvider searchProvider;
        @JiraImport
        private final SearchRequestService searchRequestService;
        @JiraImport
        private final IssueFactory issueFactory;
        @JiraImport
        private final CustomFieldManager customFieldManager;
        @JiraImport
        private final IssueIndexManager issueIndexManager;
        @JiraImport
        private final SearchService searchService;
        @JiraImport
        private final FieldVisibilityManager fieldVisibilityManager;
        @JiraImport
        private final FieldManager fieldManager;
        @JiraImport
        private final ProjectManager projectManager;
        @JiraImport
        private final ReaderCache readerCache;
    
        public SingleLevelGroupByReportExtended(final SearchProvider searchProvider,
                                                final SearchRequestService searchRequestService, final IssueFactory issueFactory,
                                                final CustomFieldManager customFieldManager, final IssueIndexManager issueIndexManager,
                                                final SearchService searchService, final FieldVisibilityManager fieldVisibilityManager,
                                                final ReaderCache readerCache,
                                                final FieldManager fieldManager,
                                                final ProjectManager projectManager) {
            this.searchProvider = searchProvider;
            this.searchRequestService = searchRequestService;
            this.issueFactory = issueFactory;
            this.customFieldManager = customFieldManager;
            this.issueIndexManager = issueIndexManager;
            this.searchService = searchService;
            this.fieldVisibilityManager = fieldVisibilityManager;
            this.readerCache = readerCache;
            this.fieldManager = fieldManager;
            this.projectManager = projectManager;
        }
    
        public StatsGroup getOptions(SearchRequest sr, ApplicationUser user, StatisticsMapper mapper) throws PermissionException {
            try {
                return searchMapIssueKeys(sr, user, mapper);
            } catch (SearchException e) {
                log.error("Exception rendering " + this.getClass().getName() + ".  Exception \n" + Arrays.toString(e.getStackTrace()));
                return null;
            }
        }
    
        public StatsGroup searchMapIssueKeys(SearchRequest request, ApplicationUser searcher, StatisticsMapper mapper) throws SearchException {
            try {
                UtilTimerStack.push("Search Count Map");
                StatsGroup statsGroup = new StatsGroup(mapper);
                Collector hitCollector = new OneDimensionalDocIssueHitCollector(mapper.getDocumentConstant(), statsGroup,
                        issueIndexManager.getIssueSearcher().getIndexReader(), issueFactory,
                        fieldVisibilityManager, readerCache, fieldManager, projectManager);
                searchProvider.searchAndSort((request != null) ? request.getQuery() : null, searcher, hitCollector, PagerFilter.getUnlimitedFilter());
                return statsGroup;
            } finally {
                UtilTimerStack.pop("Search Count Map");
            }
        }
    
        public String generateReportHtml(ProjectActionSupport action, Map params) throws Exception {
    
            String filterId = (String) params.get("filterid");
            if (filterId == null) {
                log.info("Single Level Group By Report run without a project selected (JRA-5042): params=" + params);
                return "<span class='errMsg'>No search filter has been selected. Please "
                        + "<a href=\"IssueNavigator.jspa?reset=Update&pid="
                        + TextUtils.htmlEncode((String) params.get("selectedProjectId"))
                        + "\">create one</a>, and re-run this report.";
            }
            String mapperName = (String) params.get("mapper");
            final StatisticsMapper mapper = new FilterStatisticsValuesGenerator().getStatsMapper(mapperName);
            final JiraServiceContext ctx = new JiraServiceContextImpl(action.getLoggedInUser());
            final SearchRequest request = searchRequestService.getFilter(ctx, new Long(filterId));
    
            try {
                final Map startingParams = ImmutableMap.builder()
                        .put("action", action)
                        .put("statsGroup", getOptions(request, action.getLoggedInUser(), mapper))
                        .put("searchRequest", request)
                        .put("mapperType", mapperName)
                        .put("customFieldManager", customFieldManager)
                        .put("fieldVisibility", fieldVisibilityManager)
                        .put("searchService", searchService)
                        .put("portlet", this).build();
    
                return descriptor.getHtml("view", startingParams);
    
            } catch (PermissionException e) {
                log.error(e.getStackTrace());
                return null;
            }
        }
    
        public void validate(ProjectActionSupport action, Map params) {
            super.validate(action, params);
            String filterId = (String) params.get("filterid");
            if (StringUtils.isEmpty(filterId)) {
                action.addError("filterid", action.getText("report.singlelevelgroupby.filter.is.required"));
            } else {
                validateFilterId(action, filterId);
            }
        }
    
        private void validateFilterId(ProjectActionSupport action, String filterId) {
            try {
                JiraServiceContextImpl serviceContext = new JiraServiceContextImpl(
                        action.getLoggedInUser(), new SimpleErrorCollection());
                SearchRequest searchRequest = searchRequestService.getFilter(serviceContext, new Long(filterId));
                if (searchRequest == null) {
                    action.addErrorMessage(action.getText("report.error.no.filter"));
                }
            } catch (NumberFormatException nfe) {
                action.addError("filterId", action.getText("report.error.filter.id.not.a.number", filterId));
            }
        }
    }

    This is simply the original report with Atlassian Spring Scanner annotations. Next, we'll add the code that presents the time of last update to the report.

  3. Under the existing field declarations for the class, add a new field.

    1
    private final DateTimeFormatter formatter;
  4. Add the new field as a parameter passed to the class constructor.

    1
    2
    3
    4
    5
    public SingleLevelGroupByReportExtended( ...
                                              @JiraImport DateTimeFormatterFactory dateTimeFormatterFactory )
       ...
       this.formatter = dateTimeFormatterFactory.formatter().withStyle(DateTimeStyle.DATE).forLoggedInUser();
       ...
  5. In the generateReportHtml() method, add the following line:

    1
    2
    3
    4
    startingParams
                  ...
                  .put("formatter", formatter).build();
    return descriptor.getHtml("view", startingParams);

    It should appear within the try block in which the code assigns values to the parameter map.

    This code appends an additional parameter to the parameter map created by the method.

  6. Save your changes.

The modified class should look something like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
package com.atlassian.plugins.tutorial.jira.reports;

import com.atlassian.jira.bc.JiraServiceContext;
import com.atlassian.jira.bc.JiraServiceContextImpl;
import com.atlassian.jira.bc.filter.SearchRequestService;
import com.atlassian.jira.bc.issue.search.SearchService;
import com.atlassian.jira.datetime.DateTimeFormatter;
import com.atlassian.jira.datetime.DateTimeFormatterFactory;
import com.atlassian.jira.datetime.DateTimeStyle;
import com.atlassian.jira.exception.PermissionException;
import com.atlassian.jira.issue.CustomFieldManager;
import com.atlassian.jira.issue.IssueFactory;
import com.atlassian.jira.issue.fields.FieldManager;
import com.atlassian.jira.issue.index.IssueIndexManager;
import com.atlassian.jira.issue.search.ReaderCache;
import com.atlassian.jira.issue.search.SearchException;
import com.atlassian.jira.issue.search.SearchProvider;
import com.atlassian.jira.issue.search.SearchRequest;
import com.atlassian.jira.issue.statistics.FilterStatisticsValuesGenerator;
import com.atlassian.jira.issue.statistics.StatisticsMapper;
import com.atlassian.jira.issue.statistics.StatsGroup;
import com.atlassian.jira.issue.statistics.util.OneDimensionalDocIssueHitCollector;
import com.atlassian.jira.plugin.report.impl.AbstractReport;
import com.atlassian.jira.project.ProjectManager;
import com.atlassian.jira.user.ApplicationUser;
import com.atlassian.jira.util.SimpleErrorCollection;
import com.atlassian.jira.web.FieldVisibilityManager;
import com.atlassian.jira.web.action.ProjectActionSupport;
import com.atlassian.jira.web.bean.PagerFilter;
import com.atlassian.plugin.spring.scanner.annotation.component.Scanned;
import com.atlassian.plugin.spring.scanner.annotation.imports.JiraImport;
import com.atlassian.util.profiling.UtilTimerStack;
import com.google.common.collect.ImmutableMap;
import com.opensymphony.util.TextUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.log4j.Logger;
import org.apache.lucene.search.Collector;

import java.util.Arrays;
import java.util.Map;

@Scanned
public class SingleLevelGroupByReportExtended extends AbstractReport {
    private static final Logger log = Logger.getLogger(SingleLevelGroupByReportExtended.class);

    @JiraImport
    private final SearchProvider searchProvider;
    @JiraImport
    private final SearchRequestService searchRequestService;
    @JiraImport
    private final IssueFactory issueFactory;
    @JiraImport
    private final CustomFieldManager customFieldManager;
    @JiraImport
    private final IssueIndexManager issueIndexManager;
    @JiraImport
    private final SearchService searchService;
    @JiraImport
    private final FieldVisibilityManager fieldVisibilityManager;
    @JiraImport
    private final FieldManager fieldManager;
    @JiraImport
    private final ProjectManager projectManager;
    @JiraImport
    private final ReaderCache readerCache;
    private final DateTimeFormatter formatter;

    public SingleLevelGroupByReportExtended(final SearchProvider searchProvider,
                                            final SearchRequestService searchRequestService, final IssueFactory issueFactory,
                                            final CustomFieldManager customFieldManager, final IssueIndexManager issueIndexManager,
                                            final SearchService searchService, final FieldVisibilityManager fieldVisibilityManager,
                                            final ReaderCache readerCache,
                                            final FieldManager fieldManager,
                                            final ProjectManager projectManager,
                                            @JiraImport DateTimeFormatterFactory dateTimeFormatterFactory) {
        this.searchProvider = searchProvider;
        this.searchRequestService = searchRequestService;
        this.issueFactory = issueFactory;
        this.customFieldManager = customFieldManager;
        this.issueIndexManager = issueIndexManager;
        this.searchService = searchService;
        this.fieldVisibilityManager = fieldVisibilityManager;
        this.readerCache = readerCache;
        this.fieldManager = fieldManager;
        this.projectManager = projectManager;
        this.formatter = dateTimeFormatterFactory.formatter().withStyle(DateTimeStyle.DATE).forLoggedInUser();
    }

    public StatsGroup getOptions(SearchRequest sr, ApplicationUser user, StatisticsMapper mapper) throws PermissionException {
        try {
            return searchMapIssueKeys(sr, user, mapper);
        } catch (SearchException e) {
            log.error("Exception rendering " + this.getClass().getName() + ".  Exception \n" + Arrays.toString(e.getStackTrace()));
            return null;
        }
    }

    public StatsGroup searchMapIssueKeys(SearchRequest request, ApplicationUser searcher, StatisticsMapper mapper) throws SearchException {
        try {
            UtilTimerStack.push("Search Count Map");
            StatsGroup statsGroup = new StatsGroup(mapper);
            Collector hitCollector = new OneDimensionalDocIssueHitCollector(mapper.getDocumentConstant(), statsGroup,
                    issueIndexManager.getIssueSearcher().getIndexReader(), issueFactory,
                    fieldVisibilityManager, readerCache, fieldManager, projectManager);
            searchProvider.searchAndSort((request != null) ? request.getQuery() : null, searcher, hitCollector, PagerFilter.getUnlimitedFilter());
            return statsGroup;
        } finally {
            UtilTimerStack.pop("Search Count Map");
        }
    }

    public String generateReportHtml(ProjectActionSupport action, Map params) throws Exception {

        String filterId = (String) params.get("filterid");
        if (filterId == null) {
            log.info("Single Level Group By Report run without a project selected (JRA-5042): params=" + params);
            return "<span class='errMsg'>No search filter has been selected. Please "
                    + "<a href=\"IssueNavigator.jspa?reset=Update&pid="
                    + TextUtils.htmlEncode((String) params.get("selectedProjectId"))
                    + "\">create one</a>, and re-run this report.";
        }
        String mapperName = (String) params.get("mapper");
        final StatisticsMapper mapper = new FilterStatisticsValuesGenerator().getStatsMapper(mapperName);
        final JiraServiceContext ctx = new JiraServiceContextImpl(action.getLoggedInUser());
        final SearchRequest request = searchRequestService.getFilter(ctx, new Long(filterId));

        try {
            final Map startingParams = ImmutableMap.builder()
                    .put("action", action)
                    .put("statsGroup", getOptions(request, action.getLoggedInUser(), mapper))
                    .put("searchRequest", request)
                    .put("mapperType", mapperName)
                    .put("customFieldManager", customFieldManager)
                    .put("fieldVisibility", fieldVisibilityManager)
                    .put("searchService", searchService)
                    .put("portlet", this)
                    .put("formatter", formatter).build();

            return descriptor.getHtml("view", startingParams);

        } catch (PermissionException e) {
            log.error(e.getStackTrace());
            return null;
        }
    }

    public void validate(ProjectActionSupport action, Map params) {
        super.validate(action, params);
        String filterId = (String) params.get("filterid");
        if (StringUtils.isEmpty(filterId)) {
            action.addError("filterid", action.getText("report.singlelevelgroupby.filter.is.required"));
        } else {
            validateFilterId(action, filterId);
        }
    }

    private void validateFilterId(ProjectActionSupport action, String filterId) {
        try {
            JiraServiceContextImpl serviceContext = new JiraServiceContextImpl(
                    action.getLoggedInUser(), new SimpleErrorCollection());
            SearchRequest searchRequest = searchRequestService.getFilter(serviceContext, new Long(filterId));
            if (searchRequest == null) {
                action.addErrorMessage(action.getText("report.error.no.filter"));
            }
        } catch (NumberFormatException nfe) {
            action.addError("filterId", action.getText("report.error.filter.id.not.a.number", filterId));
        }
    }
}

Step 6. Create the view template for the report

In this step, we edit the report view template to display the assignee and the issue's last update time. This version differs from the original Jira report in two places where we loop over the issue result set to display the time.

Let's update the resource files that the SDK gave us.

  1. Navigate to src/main/resources/templates/reports/single-level-group-by-report-extended and open the view.vm file.
  2. Replace the placeholder content with the following:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    #enable_html_escaping()
    #if ($searchRequest)
        #set ($urlPrefix = "${req.contextPath}/secure/IssueNavigator.jspa?reset=true")
    #end
    <table width="100%" class="aui" id="single_groupby_report_table">
        <thead>
        <tr>
            <th colspan="6">
                <h2>$i18n.getText('report.singlelevelgroupby.mapper.filterid.name'): $textutils.htmlEncode($searchRequest.name)</h2>
            </th>
        </tr>
        </thead>
        <tbody>
            #foreach ($option in $statsGroup.entrySet())
                #set ($issues = $option.value)
                #set ($graphModel = $statsGroup.getResolvedIssues($option.key))
            <tr>
                <th colspan="6" class="stat-heading">
                    <div class="stat-progress">
                        <span class="graphLabel">$i18n.getText("common.words.progress"):</span>
                        #percentageGraphDiv ($graphModel)
                        #if ($issues.size() > 0)
                            <span class="graphDescription">$i18n.getText("roadmap.issuesresolved", "$statsGroup.getResolvedIssueCount($issues)", "$issues.size()")</span>
                        #end
                    </div>
                    <h3>#statHeading ($mapperType $option.key $customFieldManager "${urlPrefix}$!searchService.getQueryString($user, $statsGroup.getMapper().getSearchUrlSuffix($option.key, $searchRequest).getQuery())")</h3>
                </th>
            </tr>
                #if ($issues.size() > 0)
                    #foreach ($issue in $issues)
                    <tr>
                        <td width="5%"> </td>
                        #issueLineItem ($issue)
                        <td nowrap class="assignee">
                            #if($issue.getAssignee())
                                $issue.getAssignee().getDisplayName()
                            #else
                                $i18n.getText('common.concepts.unassigned')
                            #end</td>
                        <td nowrap class="last-updated"> $formatter.format($issue.getUpdated())</td>
                    </tr>
                    #end
                #else
                <tr>
                    <td colspan="6">
                        <span class="subText">$action.getText("common.concepts.noissues").</span>
                    </td>
                </tr>
                #end
            #end
        ## Render the Irrelevant issues if there are any
            #if($statsGroup.getIrrelevantIssues().size() > 0)
                #set ($issues = $statsGroup.getIrrelevantIssues())
                #set ($graphModel = $statsGroup.getIrrelevantResolvedIssues())
            <tr>
                <th colspan="6">
                    <div class="stat-progress">
                        <span class="graphLabel">$i18n.getText("common.words.progress"):</span>
                        #percentageGraphDiv ($graphModel)
                        #if ($issues.size() > 0)
                            <span class="graphDescription">$i18n.getText("roadmap.issuesresolved", "$statsGroup.getResolvedIssueCount($issues)", "$issues.size()")</span>
                        #end
                    </div>
                    <h3><span title="$i18n.getText('common.concepts.irrelevant.desc')">$i18n.getText('common.concepts.irrelevant')</span></h3>
                </th>
            </tr>
                #if ($issues.size() > 0)
                    #foreach ($issue in $issues)
                    <tr>
                        <td width="5%"> </td>
                        #issueLineItem ($issue)
                        <td nowrap class="assignee">
                            #if($issue.getAssignee())
                                 $issue.getAssignee().getDisplayName()
                             #else
                                $i18n.getText('common.concepts.unassigned')
                            #end</td>
                        <td nowrap class="last-updated">$formatter.format($issue.getUpdated())</td>
                    </tr>
                    #end
                #else
                <tr>
                    <td colspan="6">
                        <span class="subText">$action.getText("common.concepts.noissues").</span>
                    </td>
                </tr>
                #end
            #end
        </tbody>
    </table>

    This template code is identical to the template for the original report, but with an added table cell in the output that contains our date.

  3. Navigate to src/main/resources and open the SingleLevelGroupByReportExtended.properties file.

  4. Replace its contents with the following:

    1
    2
    3
    4
    5
    6
    7
    report.singlelevelgroupby.label.extended = Single Level Group By Report Extended
    report.singlelevelgroupby.filterId = Filter
    report.singlelevelgroupby.filterId.description = Select a filter to display
    report.singlelevelgroupby.mapper = Statistic Type
    report.singlelevelgroupby.mapper.description = Select a field to group by
    report.singlelevelgroupby.mapper.filterid.name = Filter
    report.singlelevelgroupby.description = This report allows you to display issues grouped by a certain field

    These are the text strings that will appear in the UI generated by the report.

  5. Save and close the file.

That gives us enough to try it out in Jira. You'll do that in the next step.

Step 7. Start Jira and try the report

  1. Open a Terminal and navigate to the project root directory (where the POM is located), and then run the following command:

    1
    atlas-run

    Give the SDK a few minutes to download the Jira files and start the instance. If you run into build errors, make sure you have enabled the dependency for the jira-core artifact in the project POM.

  2. Open the local Jira instance and log in with the default admin/admin credentials.

  3. Before trying out your report, create a few Jira artifacts:
    1. Create a project. The first time you start Jira, a wizard prompts you to create one.
    2. Create a few test issues in the project. To give your report a property to group by, choose different assignees or issue types for your issues. 
    3. Create at least one filter. You can do this from the search page. For more help, see the documentation on Jira filters.  
  4. Go to the Overview page for your project.
  5. In the Summary view, find your two new reports at the bottom of the list.
  6. Click Single Level Group By Report Extended
  7. Select a Filter and the statistic type you want to group by, and then click Next. Your report should look something like this.

Take a look at how the Creation Report looks in Jira. Due to the properties you added to the app descriptor, we have our user input fields. But, there are only placeholders for labels, and if you click Next, you get a blank page. You'll work on that in the next step.

Meanwhile, you can leave Jira running and load your app changes with  QuickReload.

Step 8. Write the Creation Report code

In this step we make the Creation Report do something a little more useful.

  1. Navigate to src/main/java/com/atlassian/plugins/tutorial/jira/reports and open the CreationReport.java file.

  2. Replace its contents with the following:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    package com.atlassian.plugins.tutorial.jira.reports;
    
    import com.atlassian.core.util.DateUtils;
    import com.atlassian.jira.datetime.DateTimeFormatter;
    import com.atlassian.jira.datetime.DateTimeFormatterFactory;
    import com.atlassian.jira.datetime.DateTimeStyle;
    import com.atlassian.jira.issue.search.SearchException;
    import com.atlassian.jira.issue.search.SearchProvider;
    import com.atlassian.jira.jql.builder.JqlQueryBuilder;
    import com.atlassian.jira.plugin.report.impl.AbstractReport;
    import com.atlassian.jira.project.ProjectManager;
    import com.atlassian.jira.user.ApplicationUser;
    import com.atlassian.jira.util.ParameterUtils;
    import com.atlassian.jira.web.action.ProjectActionSupport;
    import com.atlassian.plugin.spring.scanner.annotation.component.Scanned;
    import com.atlassian.plugin.spring.scanner.annotation.imports.JiraImport;
    import com.atlassian.query.Query;
    import org.apache.log4j.Logger;
    
    import java.util.ArrayList;
    import java.util.Collection;
    import java.util.Date;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    
    @Scanned
    public class CreationReport extends AbstractReport {
        private static final Logger log = Logger.getLogger(CreationReport.class);
        private static final int MAX_HEIGHT = 360;
        private long maxCount = 0;
        private Collection<Long> openIssuesCounts = new ArrayList<>();
        private Collection<String> formattedDates = new ArrayList<>();
        @JiraImport
        private final SearchProvider searchProvider;
        @JiraImport
        private final ProjectManager projectManager;
        private final DateTimeFormatter formatter;
    
        private Date startDate;
        private Date endDate;
        private Long interval;
        private Long projectId;
    
        public CreationReport(SearchProvider searchProvider, ProjectManager projectManager,
                              @JiraImport DateTimeFormatterFactory dateTimeFormatterFactory) {
            this.searchProvider = searchProvider;
            this.projectManager = projectManager;
            this.formatter = dateTimeFormatterFactory.formatter().withStyle(DateTimeStyle.DATE).forLoggedInUser();
        }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
    public String generateReportHtml(ProjectActionSupport action, Map params) throws Exception {
        //action.getLoggedInUser() since Jira 7.0.
        //getLoggedInApplicationUser() since Jira 5.2
        fillIssuesCounts(startDate, endDate, interval, action.getLoggedInUser(), projectId);
        List<Number> issueBarHeights = new ArrayList<>();
        if (maxCount > 0) {
            openIssuesCounts.forEach(issueCount ->
                    issueBarHeights.add((issueCount.floatValue() / maxCount) * MAX_HEIGHT)
            );
        }
        Map<String, Object> velocityParams = new HashMap<>();
        velocityParams.put("startDate", formatter.format(startDate));
        velocityParams.put("endDate", formatter.format(endDate));
        velocityParams.put("openCount", openIssuesCounts);
        velocityParams.put("issueBarHeights", issueBarHeights);
        velocityParams.put("dates", formattedDates);
        velocityParams.put("maxHeight", MAX_HEIGHT);
        velocityParams.put("projectName", projectManager.getProjectObj(projectId).getName());
        velocityParams.put("interval", interval);
        return descriptor.getHtml("view", velocityParams);
    }

    private long getOpenIssueCount(ApplicationUser user, Date startDate, Date endDate, Long projectId) throws SearchException {
        JqlQueryBuilder queryBuilder = JqlQueryBuilder.newBuilder();
        Query query = queryBuilder.where().createdBetween(startDate, endDate).and().project(projectId).buildQuery();
        return searchProvider.searchCount(query, user);
    }

    private void fillIssuesCounts(Date startDate, Date endDate, Long interval, ApplicationUser user, Long projectId) throws SearchException {
        long intervalValue = interval * DateUtils.DAY_MILLIS;
        Date newStartDate;
        long count;
        while (startDate.before(endDate)) {
            newStartDate = new Date(startDate.getTime() + intervalValue);
            if (newStartDate.after(endDate))
                count = getOpenIssueCount(user, startDate, endDate, projectId);
            else
                count = getOpenIssueCount(user, startDate, newStartDate, projectId);
            if (maxCount < count)
                maxCount = count;
            openIssuesCounts.add(count);
            formattedDates.add(formatter.format(startDate));
            startDate = newStartDate;
        }
    }

    public void validate(ProjectActionSupport action, Map params) {
        try {
            startDate = formatter.parse(ParameterUtils.getStringParam(params, "startDate"));
        } catch (IllegalArgumentException e) {
            action.addError("startDate", action.getText("report.issuecreation.startdate.required"));
            log.error("Exception while parsing startDate");
        }
        try {
            endDate = formatter.parse(ParameterUtils.getStringParam(params, "endDate"));
        } catch (IllegalArgumentException e) {
            action.addError("endDate", action.getText("report.issuecreation.enddate.required"));
            log.error("Exception while parsing endDate");
        }

        interval = ParameterUtils.getLongParam(params, "interval");
        projectId = ParameterUtils.getLongParam(params, "selectedProjectId");
        if (interval == null || interval <= 0) {
            action.addError("interval", action.getText("report.issuecreation.interval.invalid"));
            log.error("Invalid interval");
        }
        if (projectId == null || projectManager.getProjectObj(projectId) == null){
            action.addError("selectedProjectId", action.getText("report.issuecreation.projectid.invalid"));
            log.error("Invalid projectId");
        }
        if (startDate != null && endDate != null && endDate.before(startDate)) {
            action.addError("endDate", action.getText("report.issuecreation.before.startdate"));
            log.error("Invalid dates: start date should be before end date");
        }
    }
}
```

The code retrieves the parameters specified by the user. Then it gets the relevant issue counts from the configured time range;
they are divided into the time intervals specified by the user. The issue counts are normalized, giving the user a balanced histogram.
Finally, the relevant details are passed to the Velocity template.
  1. Save and close the file.

Step 9. Develop the Creation Report UI

If you try the report in Jira now, you will still get a blank page. To see a report, you need to code a presentation template, along with text strings that will appear in the UI.

  1. Navigate to src/main/resources/templates/reports/creation-report and open the view.vm file.
  2. Replace its content with the following:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    <div style="padding: 5px">
       <!-- Display the report configuration -->
       <h4 id="creation-report-parameters">
           $i18n.getText('report.issuecreation.project'): $projectName |
           $i18n.getText('report.issuecreation.duration'):$startDate - $endDate |
           $i18n.getText('report.issuecreation.interval'): $interval $i18n.getText('report.issuecreation.interval.days')
       </h4>
       <br />
       <table style="width: 100%; border: 0; background-color: lightgrey">
           <!-- Create a row to display the bars-->
           <tr valign="bottom" style="background-color: white; padding: 1px">
               #foreach ($issueBarHeight in $issueBarHeights)
                   <td height="$maxHeight" align="center">
                       #if ($issueBarHeight > 0)
                           <img src="${baseurl}/images/bluepixel.gif" width="12" height="$issueBarHeight">
                       #end
                   </td>
               #end
           </tr>
           <!-- Have one row for the issue count -->
           <tr style="background-color: #eee; padding: 1px">
               #foreach ($count in $openCount)
                   <td align="center"><b>$count</b></td>
               #end
           </tr>
           <!-- And one row to display the date -->
           <tr style="background-color: #eee; padding: 1px">
               #foreach ($date in $dates)
                   <td align="center"><b>$date</b></td>
               #end
           </tr>
       </table>
    </div>
  3. Add the following text strings to the properties file:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    report.issuecreation.label = Issue Creation Report
    report.issuecreation.name = Issue Creation Report
    report.issuecreation.projectid.name = Project
    report.issuecreation.projectid.description = Select the project to display report on.
    report.issuecreation.description = Report displaying a histogram of issues opened over a specified period.
    report.issuecreation.startdate = Start Date
    report.issuecreation.startdate.description = Graph all issues created after this date.
    report.issuecreation.enddate = End Date
    report.issuecreation.enddate.description = Graph all issues created before this date.
    report.issuecreation.interval = Interval
    report.issuecreation.interval.days = days
    report.issuecreation.interval.description = Specify the interval (in days) for the report.
    report.issuecreation.startdate.required = A valid "Start Date" is required to generate this report.
    report.issuecreation.enddate.required = A valid "End Date" is required to generate this report.
    report.issuecreation.interval.invalid = The interval must be a number greater than 0.
    report.issuecreation.before.startdate = The "End Date" must be after the "Start Date".
    report.issuecreation.error = Error occurred generating Issue Creation Report.
    report.issuecreation.projectid.invalid = Please select a valid project.
    report.issuecreation.default.interval = The interval specified is invalid - using default interval.
    report.issuecreation.duration = Duration
    report.issuecreation.project = Project
  4. Save and close the file.

Step 10. View the finished report

Now try the Creation Report again and see what we've got.

  1. Start Jira instance using atlas-run command or, if you left Jira running, simply rebuild your app with atlas-package command. As mentioned,  QuickReload will automatically reinstall your app for you.
  2. At the bottom of the project summary page, click the Creation Report link.
  3. Supply dates that follow these criteria:
    1. Dates make a range that encompasses the date on which you created the issues.
    2. The interval is long enough to give you a few intervals to look at (keep in mind that the default interval is 3 days).
  4. Click Next
    This time, we have a report.

Congratulations, that's it!

Have a treat!

Next Steps

Our report output UI is pretty basic. As a next step, try improving the report output. You can use the reports supplied with Jira as a model.

For more information on topics covered in this tutorial, see: