Build a Jira issue importer and exporter
Move Northwind tasks between Jira and Sheets — useful for client teams on Jira.
Published Sep 25, 2025
Some of Northwind’s clients live in Jira and the studio team lives in Sheets. That split means status updates get retyped, copied, and quietly fall out of sync — a stand-up where half the board is a day stale. The fix is not to move everyone onto one tool; it is to mirror the issues both ways.
This script does both directions. pullJira reads issues from a Jira project
and rebuilds a mirror sheet so the team can see the client’s board without a
Jira login. pushJira reads a status column the team edits and writes those
changes back to Jira through the REST API, so the studio’s view stays the
source of truth for the work they own.
What you’ll need
- A Jira Cloud site and a project you have access to — the examples use the
NWproject onnorthwind.atlassian.net. - A Jira API token, created at id.atlassian.com → Security → API tokens,
saved as
JIRA_TOKENin Script Properties. Never paste it into the code — see Store API keys and secrets securely. - A Google Sheet to act as the mirror. The first sheet receives the imported issues; the script writes its own headers.
- Knowing your Jira transition IDs if you plan to push status changes — Jira moves an issue by transition, not by status name (see “Watch out for”).
The script
// Your Jira Cloud site and the account the API token belongs to.
const JIRA_BASE = 'https://northwind.atlassian.net';
const JIRA_USER = '[email protected]';
// The spreadsheet that mirrors the Jira project.
const MIRROR_SHEET_ID = '1abcJiraMirrorId';
// Default JQL — which issues to pull when none is passed.
const DEFAULT_JQL = 'project = NW';
/**
* Builds the Basic auth header Jira Cloud expects: base64 of
* "email:apiToken". The token is read from Script Properties.
*/
function jiraAuthHeader() {
const token = PropertiesService.getScriptProperties()
.getProperty('JIRA_TOKEN');
return 'Basic ' + Utilities.base64Encode(JIRA_USER + ':' + token);
}
/**
* Imports Jira issues matching a JQL query and rebuilds the mirror
* sheet so it always reflects the current Jira state.
*/
function pullJira(jql = DEFAULT_JQL) {
// 1. Search Jira for the matching issues.
const res = UrlFetchApp.fetch(
JIRA_BASE + '/rest/api/3/search?jql=' + encodeURIComponent(jql),
{ headers: { Authorization: jiraAuthHeader() }, muteHttpExceptions: true }
);
const data = JSON.parse(res.getContentText());
if (!data.issues || !data.issues.length) {
Logger.log('No issues returned for JQL: ' + jql);
return;
}
// 2. Flatten each issue into a row — key, summary, status, assignee.
const rows = data.issues.map((i) => [
i.key,
i.fields.summary,
i.fields.status.name,
i.fields.assignee?.displayName || '',
]);
// 3. Rebuild the mirror sheet from scratch so deleted issues drop off.
const sheet = SpreadsheetApp.openById(MIRROR_SHEET_ID).getSheets()[0];
sheet.clear();
sheet.getRange(1, 1, 1, 4)
.setValues([['key', 'summary', 'status', 'assignee']]);
sheet.getRange(2, 1, rows.length, 4).setValues(rows);
Logger.log('Mirrored ' + rows.length + ' issues from Jira.');
}
/**
* Pushes a status transition back to Jira for a single issue.
* Jira changes status via a transition ID, not a status name.
*/
function pushTransition(issueKey, transitionId) {
UrlFetchApp.fetch(
JIRA_BASE + '/rest/api/3/issue/' + issueKey + '/transitions',
{
method: 'post',
contentType: 'application/json',
headers: { Authorization: jiraAuthHeader() },
payload: JSON.stringify({ transition: { id: transitionId } }),
muteHttpExceptions: true,
}
);
}
/**
* Reads a "transition" column the team fills in on the mirror sheet
* and pushes each non-blank transition back to Jira.
*/
function pushJira() {
const sheet = SpreadsheetApp.openById(MIRROR_SHEET_ID).getSheets()[0];
const [header, ...rows] = sheet.getDataRange().getValues();
const keyCol = header.indexOf('key');
const transCol = header.indexOf('transition');
if (transCol === -1) {
Logger.log('Add a "transition" column to push changes.');
return;
}
// Push every row that has a transition ID typed in.
let pushed = 0;
for (const r of rows) {
if (r[transCol]) {
pushTransition(r[keyCol], r[transCol]);
pushed++;
}
}
Logger.log('Pushed ' + pushed + ' transitions to Jira.');
}
How it works
jiraAuthHeaderbuilds the Basic auth string Jira Cloud expects — base64 ofemail:token— pulling the token from Script Properties so it never lands in the code.pullJiracalls Jira’s/searchendpoint with a JQL query. JQL is Jira’s filter language, so you can pull a whole project or justassignee = me.- If the search returns no issues it logs and stops rather than clearing the sheet for nothing.
- Each issue is flattened to four fields.
assigneeuses optional chaining because an unassigned issue has anullassignee object. - The mirror sheet is cleared and rebuilt every run, so issues closed or removed in Jira disappear from the sheet too.
pushTransitionposts to Jira’s/transitionsendpoint. Jira does not let you set a status directly — you trigger a transition, identified by ID.pushJirareads atransitioncolumn the team edits, and for every row with an ID typed in it fires the matching transition back to Jira.
Example run
After pullJira() the mirror sheet holds the current Jira board:
| key | summary | status | assignee |
|---|---|---|---|
| NW-104 | Fix export encoding | In Progress | Priya |
| NW-105 | Client onboarding doc | To Do | (blank) |
| NW-106 | Q3 invoice reconcile | Done | Awadesh |
To move NW-105 forward, the team adds a transition column and types the
transition ID (say 21 for “Start work”):
| key | summary | status | assignee | transition |
|---|---|---|---|---|
| NW-105 | Client onboarding doc | To Do | (blank) | 21 |
Running pushJira() fires that transition and the next pullJira() shows
NW-105 as In Progress.
Run it
These run on demand or on a schedule, depending on the direction:
- Run
pullJiraby hand whenever you want a fresh import, or add a Time- driven trigger (a Day timer or hourly) to keep the mirror current. - Run
pushJiraafter editing thetransitioncolumn. - Approve the authorisation prompt the first time each runs.
To list an issue’s valid transition IDs, fetch
GET /rest/api/3/issue/{key}/transitions once and read them from the response.
Watch out for
- Jira’s
/searchendpoint is paginated and returns 50 issues by default. For a large project, add&maxResults=100and loop withstartAtuntil you have them all, or the mirror will be truncated. - Status is set by transition, not by name. A transition that exists for one issue may not exist for another — the IDs depend on the issue’s current status and the project’s workflow.
- The API token inherits your Jira permissions. If you cannot see an issue in Jira, the script cannot either.
pullJirarebuilds the sheet, so any extra columns the team adds to the right (liketransitionor notes) get wiped on import. Keep editable columns on a separate tab, or rework the import to merge bykeyinstead of clearing.UrlFetchAppis subject to a daily quota. A frequent trigger across many projects can use it up — pull no more often than the team actually needs.
Related
Sync calendar bookings with Calendly
Bridge Google Calendar and Calendly — Northwind bookings on either side appear on both.
Updated Jan 7, 2026
Connect to an air-quality and weather feed
Build a Northwind environmental dashboard — current London AQI plus 5-day forecast.
Updated Dec 30, 2025
Build a podcast and media stats tracker
Pull Northwind's podcast download numbers across platforms into a single sheet.
Updated Dec 10, 2025
Track real-estate listings for new matches
Monitor property feeds for Northwind office hunts — alert when a match appears.
Updated Nov 28, 2025
Translate columns with a translation API
Localise Northwind text in bulk without manual work — via Google Translate or DeepL.
Updated Nov 24, 2025