Anonymous avatar Anonymous committed ac6cfb9

Moving to the BB atlassian_tutorial

Comments (0)

Files changed (14)

+To avoid future confusion, we recommend that you include a license with your plugin.
+This file is simply a reminder.
+
+For a template license you can have a look at: http://www.opensource.org/licenses/
+
+Atlassian releases most of its modules under a BSD license: http://www.opensource.org/licenses/bsd-license.php
+shows you how to extend an existing JIRA system report in the first example and then how 
+to create your own JIRA custom report from scratch in the second example.
+
+Full documentation is always available at:
+
+https://developer.atlassian.com/display/JIRADEV/Plugin+Tutorial+-+Creating+a+JIRA+Report
+<?xml version="1.0" encoding="UTF-8"?>
+
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <groupId>com.atlassian.plugins.tutorial</groupId>
+    <artifactId>jira-report-plugin</artifactId>
+    <version>1.0-SNAPSHOT</version>
+
+    <organization>
+        <name>Example Company</name>
+        <url>http://www.example.com/</url>
+    </organization>
+
+    <name>jira-report-plugin</name>
+    <description>This is the com.atlassian.tutorial:jira-report-plugin plugin for Atlassian JIRA.</description>
+    <packaging>atlassian-plugin</packaging>
+
+    <dependencies>
+        <dependency>
+            <groupId>com.atlassian.jira</groupId>
+            <artifactId>atlassian-jira</artifactId>
+            <version>${jira.version}</version>
+            <scope>provided</scope>
+        </dependency>        
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <version>4.6</version>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.atlassian.jira</groupId>
+            <artifactId>jira-func-tests</artifactId>
+            <version>${jira.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>com.atlassian.maven.plugins</groupId>
+                <artifactId>maven-jira-plugin</artifactId>
+                <version>3.0.5</version>
+                <extensions>true</extensions>
+                <configuration>
+                    <productVersion>${jira.version}</productVersion>
+                    <productDataVersion>${jira.data.version}</productDataVersion>
+                </configuration>
+            </plugin>
+            <plugin>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <configuration>
+                    <source>1.5</source>
+                    <target>1.5</target>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+
+    <properties>
+        <jira.version>4.1.1.1</jira.version>
+        <jira.data.version>4.1.1</jira.data.version>
+    </properties>
+
+</project>

src/main/java/com/atlassian/plugins/tutorial/jira/report/CreationReport.java

+package com.atlassian.plugins.tutorial.jira.report;
+
+import com.atlassian.core.util.DateUtils;
+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.util.I18nHelper;
+import com.atlassian.jira.util.ParameterUtils;
+import com.atlassian.jira.web.action.ProjectActionSupport;
+import com.atlassian.jira.web.bean.I18nBean;
+import com.atlassian.jira.web.util.OutlookDate;
+import com.atlassian.jira.web.util.OutlookDateManager;
+import com.atlassian.query.Query;
+import com.opensymphony.user.User;
+
+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;
+
+/**
+ * Generate a histogram displaying number of issues opened in a specified period.
+ * The time period is divided by the specifed value for the histogram display.
+ */
+public class CreationReport extends AbstractReport
+{
+    private static final Logger log = Logger.getLogger(CreationReport.class);
+
+    // The max height for each bar in the histogram
+    private static final int MAX_HEIGHT = 200;
+    // Default interval value
+    private Long DEFAULT_INTERVAL = new Long(7);
+
+    // The highest issue count encountered in a search
+    private long maxCount = 0;
+    // A collection of issue open counts
+    private Collection<Long> openIssueCounts = new ArrayList<Long>();
+    // A collection of interval start dates - correlating with the openIssueCount collection.
+    private Collection<Date> dates = new ArrayList<Date>();
+
+    private final SearchProvider searchProvider;
+    private final OutlookDateManager outlookDateManager;
+    private final ProjectManager projectManager;
+
+    public CreationReport(SearchProvider searchProvider, OutlookDateManager outlookDateManager, ProjectManager projectManager)
+    {
+        this.searchProvider = searchProvider;
+        this.outlookDateManager = outlookDateManager;
+        this.projectManager = projectManager;
+    }
+
+    // Generate the report
+    public String generateReportHtml(ProjectActionSupport action, Map params) throws Exception
+    {
+        User remoteUser = action.getRemoteUser();
+        I18nHelper i18nBean = new I18nBean(remoteUser);
+
+        // Retrieve the project parameter
+        Long projectId = ParameterUtils.getLongParam(params, "projectid");
+        // Retrieve the start and end dates and the time interval specified by the user
+        Date startDate = ParameterUtils.getDateParam(params, "startDate", i18nBean.getLocale());
+        Date endDate = ParameterUtils.getDateParam(params, "endDate", i18nBean.getLocale());
+        Long interval = ParameterUtils.getLongParam(params, "interval");
+
+        // Ensure that the interval is valid
+        if (interval == null || interval.longValue() <= 0)
+        {
+            interval = DEFAULT_INTERVAL;
+            log.error(action.getText("report.issuecreation.default.interval"));
+        }
+
+        getIssueCount(startDate, endDate, interval, remoteUser, projectId);
+
+        List<Number> normalCount = new ArrayList<Number>();
+
+        // Normalise the counts for the max height
+        if (maxCount != MAX_HEIGHT && maxCount > 0)
+        {
+            for (Long asLong : openIssueCounts)
+            {
+                Float floatValue = new Float((asLong.floatValue() / maxCount) * MAX_HEIGHT);
+                // Round it back to an integer
+                Integer newValue = new Integer(floatValue.intValue());
+
+                normalCount.add(newValue);
+            }
+        }
+
+        if (maxCount < 0)
+            action.addErrorMessage(action.getText("report.issuecreation.error"));
+
+        // Pass the issues to the velocity template
+        Map<String, Object> velocityParams = new HashMap<String, Object>();
+        velocityParams.put("startDate", startDate);
+        velocityParams.put("endDate", endDate);
+        velocityParams.put("openCount", openIssueCounts);
+        velocityParams.put("normalisedCount", normalCount);
+        velocityParams.put("dates", dates);
+        velocityParams.put("maxHeight", new Integer(MAX_HEIGHT));
+        velocityParams.put("outlookDate", outlookDateManager.getOutlookDate(i18nBean.getLocale()));
+        velocityParams.put("projectName", projectManager.getProjectObj(projectId).getName());
+        velocityParams.put("interval", interval);
+
+        return descriptor.getHtml("view", velocityParams);
+    }
+
+    // Retrieve the issues opened during the time period specified.
+    private long getOpenIssueCount(User remoteUser, 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, remoteUser);
+    }
+
+    private void getIssueCount(Date startDate, Date endDate, Long interval, User remoteUser, Long projectId) throws SearchException
+    {
+        // Calculate the interval value in milliseconds
+        long intervalValue = interval.longValue() * DateUtils.DAY_MILLIS;
+        Date newStartDate;
+        long count = 0;
+
+        // Split the specified time period by the interval value
+        while (startDate.before(endDate))
+        {
+            newStartDate = new Date(startDate .getTime() + intervalValue);
+
+            // Retrieve the issues opened within the time interval
+            if (newStartDate.after(endDate))
+                count = getOpenIssueCount(remoteUser, startDate, endDate, projectId);
+            else
+                count = getOpenIssueCount(remoteUser, startDate, newStartDate, projectId);
+
+            // Store the highest count for normalisation of results
+            if (maxCount < count)
+                maxCount = count;
+
+            // Store the count and the start date for this period
+            openIssueCounts.add(new Long(count));
+            dates.add(startDate);
+
+            // Move start date to next period
+            startDate = newStartDate;
+        }
+    }
+
+    // Validate the parameters set by the user.
+    public void validate(ProjectActionSupport action, Map params)
+    {
+        User remoteUser = action.getRemoteUser();
+        I18nHelper i18nBean = new I18nBean(remoteUser);
+
+        Date startDate = ParameterUtils.getDateParam(params, "startDate", i18nBean.getLocale());
+        Date endDate = ParameterUtils.getDateParam(params, "endDate", i18nBean.getLocale());
+        Long interval = ParameterUtils.getLongParam(params, "interval");
+        Long projectId = ParameterUtils.getLongParam(params, "projectid");
+
+        OutlookDate outlookDate = outlookDateManager.getOutlookDate(i18nBean.getLocale());
+
+        if (startDate == null || !outlookDate.isDatePickerDate(outlookDate.formatDMY(startDate)))
+            action.addError("startDate", action.getText("report.issuecreation.startdate.required"));
+
+        if (endDate == null || !outlookDate.isDatePickerDate(outlookDate.formatDMY(endDate)))
+            action.addError("endDate", action.getText("report.issuecreation.enddate.required"));
+
+        if (interval == null || interval.longValue() <= 0)
+            action.addError("interval", action.getText("report.issuecreation.interval.invalid"));
+
+        if (projectId == null)
+            action.addError("projectid", action.getText("report.issuecreation.projectid.invalid"));
+
+        // The end date must be after the start date
+        if (startDate != null && endDate != null && endDate.before(startDate))
+        {
+            action.addError("endDate", action.getText("report.issuecreation.before.startdate"));
+        }
+    }
+}

src/main/java/com/atlassian/plugins/tutorial/jira/report/SingleLevelGroupByReportExtended.java

+package com.atlassian.plugins.tutorial.jira.report;
+
+import com.atlassian.core.util.map.EasyMap;
+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.exception.PermissionException;
+import com.atlassian.jira.issue.CustomFieldManager;
+import com.atlassian.jira.issue.IssueFactory;
+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.security.JiraAuthenticationContext;
+import com.atlassian.jira.web.FieldVisibilityManager;
+import com.atlassian.jira.web.action.ProjectActionSupport;
+import com.atlassian.jira.web.bean.FieldVisibilityBean;
+import com.atlassian.jira.web.bean.PagerFilter;
+import com.atlassian.jira.web.util.OutlookDateManager;
+import com.atlassian.util.profiling.UtilTimerStack;
+import com.opensymphony.user.User;
+import com.opensymphony.util.TextUtils;
+
+import org.apache.commons.lang.StringUtils;
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.lucene.search.HitCollector;
+
+import java.util.Map;
+
+public class SingleLevelGroupByReportExtended extends AbstractReport
+{
+    private static Log log = LogFactory.getLog(SingleLevelGroupByReportExtended.class);
+
+    private final SearchProvider searchProvider;
+    private final JiraAuthenticationContext authenticationContext;
+    private final SearchRequestService searchRequestService;
+    private final IssueFactory issueFactory;
+    private final CustomFieldManager customFieldManager;
+    private final IssueIndexManager issueIndexManager;
+    private final SearchService searchService;
+    private final FieldVisibilityManager fieldVisibilityManager;
+    private final ReaderCache readerCache;
+    private final OutlookDateManager outlookDateManager;
+
+    public SingleLevelGroupByReportExtended(final SearchProvider searchProvider, final JiraAuthenticationContext authenticationContext,
+            final SearchRequestService searchRequestService, final IssueFactory issueFactory,
+            final CustomFieldManager customFieldManager, final IssueIndexManager issueIndexManager,
+            final SearchService searchService, final FieldVisibilityManager fieldVisibilityManager,
+            final ReaderCache readerCache, final OutlookDateManager outlookDateManager)
+    {
+        this.searchProvider = searchProvider;
+        this.authenticationContext = authenticationContext;
+        this.searchRequestService = searchRequestService;
+        this.issueFactory = issueFactory;
+        this.customFieldManager = customFieldManager;
+        this.issueIndexManager = issueIndexManager;
+        this.searchService = searchService;
+        this.fieldVisibilityManager = fieldVisibilityManager;
+        this.readerCache = readerCache;
+        this.outlookDateManager = outlookDateManager;
+    }
+
+    public StatsGroup getOptions(SearchRequest sr, User user, StatisticsMapper mapper) throws PermissionException
+    {
+
+        try
+        {
+            return searchMapIssueKeys(sr, user, mapper);
+        }
+        catch (SearchException e)
+        {
+            log.error("Exception rendering " + this.getClass().getName() + ".  Exception " + e.getMessage(), e);
+            return null;
+        }
+    }
+
+    public StatsGroup searchMapIssueKeys(SearchRequest request, User searcher, StatisticsMapper mapper)
+            throws SearchException
+    {
+        try
+        {
+            UtilTimerStack.push("Search Count Map");
+            StatsGroup statsGroup = new StatsGroup(mapper);
+            HitCollector hitCollector = new OneDimensionalDocIssueHitCollector(mapper.getDocumentConstant(), statsGroup, 
+                    issueIndexManager.getIssueSearcher().getIndexReader(), issueFactory,
+                    fieldVisibilityManager, readerCache);
+            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.error("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&amp;pid="
+                   + TextUtils.htmlEncode((String) params.get("selectedProjectId"))
+                   + "\">create one</a>, and re-run this report. See also "
+                   + "<a href=\"http://jira.atlassian.com/browse/JRA-5042\">JRA-5042</a></span>";
+        }
+        String mapperName = (String) params.get("mapper");
+        final StatisticsMapper mapper = new FilterStatisticsValuesGenerator().getStatsMapper(mapperName);
+        final JiraServiceContext ctx = new JiraServiceContextImpl(authenticationContext.getUser());
+        final SearchRequest request = searchRequestService.getFilter(ctx, new Long(filterId));
+
+        final Map startingParams;
+        try
+        {
+            startingParams = EasyMap.build(
+                    "action", action,
+                    "statsGroup", getOptions(request, authenticationContext.getUser(), mapper),
+                    "searchRequest", request,
+                    "mapperType", mapperName,
+                    "customFieldManager", customFieldManager,
+                    "fieldVisibility", new FieldVisibilityBean(),
+                    "searchService", searchService,
+                    "portlet", this);
+            
+            startingParams.put("outlookDate", outlookDateManager.getOutlookDate(authenticationContext.getLocale()));
+
+            return descriptor.getHtml("view", startingParams);
+        }
+        catch (PermissionException e)
+        {
+            log.error(e, e);
+            return null;
+        }
+
+    }
+
+    public void validate(ProjectActionSupport action, Map params)
+    {
+        super.validate(action, params);
+        if (StringUtils.isEmpty((String) params.get("filterid")))
+        {
+            action.addError("filterid", action.getText("report.singlelevelgroupby.filter.is.required"));
+        }
+    }
+}

src/main/resources/atlassian-plugin.xml

+<atlassian-plugin key="${project.groupId}.${project.artifactId}" name="${project.artifactId}" plugins-version="2">
+    <plugin-info>
+        <description>${project.description}</description>
+        <version>${project.version}</version>
+        <vendor name="${project.organization.name}" url="${project.organization.url}" />
+    </plugin-info>
+
+    <!-- An simple example of customising an exisiting report - adding the admin and updated date of an issue to the template -->
+    <report key="singlelevelgroupbyextended" name="Example: Group By Report Extended" class="com.atlassian.plugins.tutorial.jira.report.SingleLevelGroupByReportExtended">
+        <description key="report.singlelevelgroupby.description">i18n description</description>
+        <resource type="velocity" name="view" location="templates/groupreport/single-groupby-report-extended.vm" />
+        <resource type="i18n" name="i18n" location="com.atlassian.plugins.tutorial.jira.report.singlelevelgroup_report" />
+        <label key="report.singlelevelgroupby.label.extended" />
+        <properties>
+            <property>
+                <key>filterid</key>
+                <name>report.singlelevelgroupby.filterId</name>
+                <description>report.singlelevelgroupby.filterId.description</description>
+                <type>select</type>
+                <values class="com.atlassian.jira.portal.SearchRequestValuesGenerator" />
+            </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>
+    </report>
+
+    <!-- An 'Issue Creation' Report - displays a histogram of issue create within a specifed project over a specified time -->
+    <report key="issuecreationreport" name="Example: Issue Creation Report" class="com.atlassian.plugins.tutorial.jira.report.CreationReport">
+        <description key="report.issuecreation.description">i18n description</description>
+        <label key="report.issuecreation.label" />
+
+        <resource type="velocity" name="view" location="templates/creationreport/issuecreation-report.vm" />
+        <resource type="i18n" name="i18n" location="com.atlassian.plugins.tutorial.jira.report.issuecreation_report" />
+
+        <properties>
+            <property>
+                <key>projectid</key>
+                <name>report.issuecreation.projectid.name</name>
+                <description>report.issuecreation.projectid.description</description>
+                <type>select</type>
+                <values class="com.atlassian.jira.portal.ProjectValuesGenerator"/>
+            </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>
+    </report>
+    
+</atlassian-plugin>

src/main/resources/com/atlassian/plugins/tutorial/jira/report/issuecreation_report.properties

+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

src/main/resources/com/atlassian/plugins/tutorial/jira/report/singlelevelgroup_report.properties

+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

src/main/resources/templates/creationreport/issuecreation-report.vm

+<div style="padding: 5px">
+<!-- Display the report configuration -->
+<h4>
+    $i18n.getText('report.issuecreation.project'): $projectName | 
+    $i18n.getText('report.issuecreation.duration'): $outlookDate.formatDMY($startDate) - $outlookDate.formatDMY($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 ($normalCount in $normalisedCount)
+            <td height="$maxHeight" align="center">
+            #if ($normalCount == 0)
+                &nbsp;
+            #else
+                <img src="${baseurl}/images/bluepixel.gif" width="12" height="$normalCount">
+            #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>$outlookDate.formatDMY($date)</b></td>
+        #end
+    </tr>
+</table>
+</div>

src/main/resources/templates/groupreport/single-groupby-report-extended.vm

+#if ($searchRequest)
+    #set ($urlPrefix = "${req.contextPath}/secure/IssueNavigator.jspa?reset=true")
+#end
+<table width="100%" class="report" id="single_groupby_report_table">
+<tr><th class="reportHeading" colspan="9">
+    <h3 class="formtitle">$i18n.getText('report.singlelevelgroupby.mapper.filterid.name'): $textutils.htmlEncode($searchRequest.name)</h3>
+</th></tr>
+    #foreach ($option in $statsGroup.entrySet())
+    #set ($issues = $option.value)
+
+        <tr>
+            <th colspan="9" class="subHeading">
+                <h3 class="bluetext">
+                #statHeading ($mapperType $option.key $customFieldManager "${urlPrefix}$!searchService.getQueryString($user, $statsGroup.getMapper().getSearchUrlSuffix($option.key, $searchRequest).getQuery())")
+                </h3>
+
+                #set ($graphModel = $statsGroup.getResolvedIssues($option.key))
+
+                #percentageGraphDiv ($graphModel)
+                <span class="graphLabel">$i18n.getText("common.words.progress"):</span>
+                <br />
+                #if ($issues.size() > 0)
+                    <span class="graphDescription">$i18n.getText("roadmap.issuesresolved", "$statsGroup.getResolvedIssueCount($issues)", "$issues.size()")</span>
+                #end
+            </th>
+        </tr>
+
+        #if ($issues.size() > 0)
+            #foreach ($issue in $issues)
+                <tr>
+                    <td width="5%">&nbsp;</td>
+                    #issueLineItem ($issue)
+                    <td nowrap>
+                        #if($issue.getAssignee())
+                            $issue.getAssignee().getFullName()
+                        #else
+                            $i18n.getText('common.concepts.unassigned')
+                        #end</td>
+                    <td nowrap>$outlookDate.format($issue.getUpdated())</td>
+                </tr>
+            #end
+        #else
+            <tr>
+                <td>&nbsp;</td>
+                <td colspan="8">
+                    <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())
+        <tr>
+            <th colspan="9" class="subHeading">
+                <h3 class="bluetext">
+                    <span title="$i18n.getText('common.concepts.irrelevant.desc')">$i18n.getText('common.concepts.irrelevant')</span>
+                </h3>
+
+                #set ($graphModel = $statsGroup.getIrrelevantResolvedIssues())
+
+                #percentageGraphDiv ($graphModel)
+                <span class="graphLabel">$i18n.getText("common.words.progress"):</span>
+                <br />
+                #if ($issues.size() > 0)
+                    <span class="graphDescription">$i18n.getText("roadmap.issuesresolved", "$statsGroup.getResolvedIssueCount($issues)", "$issues.size()")</span>
+                #end
+            </th>
+        </tr>
+
+        #if ($issues.size() > 0)
+            #foreach ($issue in $issues)
+                <tr>
+                    <td width="5%">&nbsp;</td>
+                    #issueLineItem ($issue)
+                    <td nowrap>
+                        #if($issue.getAssignee())
+                            $issue.getAssignee().getFullName()
+                        #else
+                            $i18n.getText('common.concepts.unassigned')
+                        #end</td>
+                    <td nowrap>$outlookDate.format($issue.getUpdated())</td>
+                </tr>
+            #end
+        #else
+            <tr>
+                <td>&nbsp;</td>
+                <td colspan="8">
+                    <span class="subText">$action.getText("common.concepts.noissues").</span>
+                </td>
+            </tr>
+        #end
+
+    #end
+
+</table>

src/test/java/com/atlassian/tutorial/jira/report/MyPluginTest.java

+package com.atlassian.tutorial.jira.report;
+
+import org.junit.Test;
+
+public class MyPluginTest
+{
+    @Test
+    public void testSomething()
+    {
+    }
+}

src/test/java/it/MyPluginTest.java

+package it;
+
+import org.junit.Test;
+
+public class MyPluginTest
+{
+    @Test
+    public void integrationTest()
+    {
+    }
+}

src/test/resources/TEST_RESOURCES_README

+Create any of the test resources you might need in this directory.
+
+Please remove this file before releasing your plugin.

src/test/xml/TEST_XML_RESOURCES_README

+Create all XML test resources here - these might be needed for populating JIRA instance at the integration-test phase with test data.
+
+Please remove this file before releasing your plugin.
Tip: Filter by directory path e.g. /media app.js to search for public/media/app.js.
Tip: Use camelCasing e.g. ProjME to search for ProjectModifiedEvent.java.
Tip: Filter by extension type e.g. /repo .js to search for all .js files in the /repo directory.
Tip: Separate your search with spaces e.g. /ssh pom.xml to search for src/ssh/pom.xml.
Tip: Use ↑ and ↓ arrow keys to navigate and return to view the file.
Tip: You can also navigate files with Ctrl+j (next) and Ctrl+k (previous) and view the file with Ctrl+o.
Tip: You can also navigate files with Alt+j (next) and Alt+k (previous) and view the file with Alt+o.