Create your first Scripted Report
A step-by-step walkthrough: from an empty report to a working time-tracking report built with HTML and JavaScript. You need basic HTML and JavaScript. You do not need to know Jira's REST API — the data arrives ready, and the API pages describe the shape it arrives in rather than how to fetch it.
Before you start
Report Builder covers most reporting needs with its visual builder. Scripted Reports exist for the cases the visual builder cannot express — a custom invoice, a diagram, an export in someone else's format, a layout no pivot table can produce. If a Universal Visual Builder report would do the job, build that instead: it is quicker to make and much easier for the next person to maintain.
Make sure your app administrator has granted you the permission to create Scripted Reports.
Note for administrators. Grant the Scripted Reports creation permission only to users with solid JavaScript knowledge and only to people you trust. Report code runs in your users' browsers with their Jira session; careless code can corrupt data, and hostile code can be used for XSS or other injection attacks.
How a report is put together
Every Scripted Report follows the same four steps, and the editor has a tab for each of them:
Declare input parameters — scope, dates, a JQL filter, whatever the user should choose. (Fields tab)
Fetch raw data based on those parameters: issues, projects, worklogs. (Script tab)
Apply your algorithm to turn the raw data into the numbers you want. (Script tab)
Format the result as a table, a chart or anything else HTML can express. (Template tab)
Step 1. Create the report
Open Report Builder, click New report, and pick Blank (custom code) from the Templates list.
Good news: the blank template is not blank. It ships with a complete, heavily commented version of the very report this page builds — summary cards, a per-project table and a per-issue table. You can follow this tutorial from scratch, or open the Script tab and read the shipped code first. Either way, rename the report before you save it.
Step 2. Declare the input parameters
Go to the Fields tab. Our report needs two parameters:
range, a Range Picker — the period we report on.
jql-field, a Jql field — an optional extra filter, with syntax checking built in.
To add a field, pick its type in the Field Type selector and press +. The pencil icon opens Edit Field, where you set the two names:
Twenty field types are available, from plain String and Checkbox to Project, User Picker, Sprint Picker and Issue Picker. Choose the most specific one that fits: a Project picker gives you validated project keys, where a String field gives you whatever the user typed.
Assume every field can be empty. Parameter validation belongs at the very top of your script, before the first Jira call.
Step 3. Write the template
The Template tab holds HTML, CSS and Handlebars expressions. We expect an array of issue objects, so we loop over it and produce one table row each.
<div class="issues-container">
<h2>Issues: work vs original estimate</h2>
<table>
<tr>
<th>Ticket</th>
<th>Actual work</th>
<th>Original estimate</th>
<th>Remaining</th>
</tr>
{{#each issues}}
<tr>
<td>{{this.key}}</td>
<td>{{this.timespent}} h</td>
<td>{{this.originalEstimate}} h</td>
<td>{{this.remaining}} h</td>
</tr>
{{/each}}
</table>
</div>Step 4. Write the script
The code runs in a top-level await scope with the SR object available, so asynchronous calls read like synchronous ones. The full API reference lives at sr-docs and on the Scripted Reports API page. Three methods carry this tutorial:
SR.fields.getValueByFieldName(fieldName)— returns a promise with the value of an input field declared on the Fields tab. Multi-select pickers return an array.SR.jira.getIssuesByJQL(jql, fields, expand)— returns a promise with an array of Jira issues. Iffieldsis omitted you get the navigable fields.SR.render(values, callback)— passes values to the template. The optional callback runs after the HTML has been rendered, which is where DOM work belongs.
Read the parameters first:
const jqlField = await SR.fields.getValueByFieldName('jql-field');
const { start, end } = await SR.fields.getValueByFieldName('range');Then build the JQL string and fetch the issues. Both parameters of getIssuesByJQL() are strings, so the field list is joined with commas:
const jql = `created >= "${start}" AND created <= "${end}"${jqlField ? ` AND ${jqlField}` : ''}`;
const foundIssues = await SR.jira.getIssuesByJQL(jql, FIELDS.join(','));Jira returns time-tracking values in seconds, not in hours. Convert them once, in a helper, rather than in the template.
Finally, shape the response into exactly what the template expects and hand it over. Here is the whole script:
/* 1. Which Jira fields we need */
const FIELDS = [
'timespent',
'timeoriginalestimate',
'timeestimate'
];
/* 2. Jira gives time tracking values in seconds */
const secondsToHours = (seconds) => {
if (isNaN(seconds)) {
return 0;
}
return Math.round(((seconds / 3600) + Number.EPSILON) * 100) / 100;
};
/* 3. Read the input parameters declared on the Fields tab */
const jqlField = await SR.fields.getValueByFieldName('jql-field');
const { start, end } = await SR.fields.getValueByFieldName('range');
/* 4. Build the JQL and fetch the issues */
const jql = `created >= "${start}" AND created <= "${end}"${jqlField ? ` AND ${jqlField}` : ''}`;
const foundIssues = await SR.jira.getIssuesByJQL(jql, FIELDS.join(','));
/* 5. Shape the response into what the template expects */
const issues = foundIssues.map(({ key, fields }) => ({
key,
timespent: secondsToHours(fields.timespent),
originalEstimate: secondsToHours(fields.timeoriginalestimate),
remaining: secondsToHours(fields.timeestimate)
}));
/* 6. Hand the data to the Handlebars template */
SR.render({ issues });Step 5. Run it
Switch to the Preview tab, fill the parameters in the floating Configure panel, and press Refresh next to the report title.
Step 6. Share it
The Permissions tab decides who else can use the report. Pick an access level, pick the audience, press Add.
View — open and run the report; cannot save parameter values or edit the configuration.
Edit — the above, plus saving parameter values.
Admin — full access, including the editor tabs.
The creator always keeps admin rights, and that cannot be changed.
Step 7. Save it
Give the report a self-descriptive name in the title field, then press Add to My Reports. It now lives in the My tab of the report list, and opens with a single click.
Be deliberate about names. Once you have a dozen reports it is tempting to call the second one "Project Status" as well. Call it "Project Delivery Status" instead — your future self will thank you.
Going further: aggregate by project
The report now compares spent and estimated time per issue. Rolling that up per project takes only a change in the script. First, add project to the field list:
const FIELDS = [
'timespent',
'timeoriginalestimate',
'timeestimate',
'project'
];Then replace the map() with a reduce() that builds both collections in one pass:
const { issues, projects } = foundIssues.reduce((result, { key, fields }) => {
const { project: { key: projectKey }, timeestimate, timeoriginalestimate, timespent } = fields;
/* the per-issue row, exactly as before */
result.issues.push({
key,
timespent: secondsToHours(timespent),
originalEstimate: secondsToHours(timeoriginalestimate),
remaining: secondsToHours(timeestimate)
});
/* the per-project totals, accumulated in seconds */
let project = result.projects.find((p) => p.key === projectKey);
if (!project) {
project = { key: projectKey, timespent: 0, originalEstimate: 0, remaining: 0 };
result.projects.push(project);
}
project.timespent += timespent || 0;
project.originalEstimate += timeoriginalestimate || 0;
project.remaining += timeestimate || 0;
return result;
}, { issues: [], projects: [] });
/* convert the project totals to hours once, at the end */
projects.forEach((p) => {
p.timespent = secondsToHours(p.timespent);
p.originalEstimate = secondsToHours(p.originalEstimate);
p.remaining = secondsToHours(p.remaining);
});
SR.render({ issues, projects });And add a second table to the template:
<div class="projects-container">
<h2>Projects: work vs original estimate</h2>
<table>
<tr>
<th>Project key</th>
<th>Actual work</th>
<th>Original estimate</th>
<th>Remaining</th>
</tr>
{{#each projects}}
<tr>
<td>{{this.key}}</td>
<td>{{this.timespent}} h</td>
<td>{{this.originalEstimate}} h</td>
<td>{{this.remaining}} h</td>
</tr>
{{/each}}
</table>
</div>Note that you did not lose the first table — the same fetched data now feeds two views. That is the point of Scripted Reports: the Jira data arrives once, and what you do with it is up to your code.
Moving a report between instances
A Scripted Report is a piece of JSON, so it travels. Open the … menu next to the report title and choose Export JSON.
On the target instance, click New report, scroll to the bottom of the Templates list and pick Import Scripted Report. Paste the JSON and press Import.
Details and edge cases: Report Builder: "How to export and import a Scripted Report" guide
Report Building Service
If you would rather have the report built for you, our Report Building Service team can do it. Read more here.