# Jira

> Send Jira issues and their comments to Dimension Labs with the universal API.

This brings in each issue from a customer-facing Jira project (a feedback, bug report or service project) as one conversation: the issue's summary and description, then its public comments, from the reporter and from your team. You run it on your side: a small script pulls from the Jira Cloud REST API and posts each message to the [universal API](/api/tracker).

## What you need

- A Jira Cloud site and an Atlassian account that can browse the project. The script sees only what that account can see.
- An API token for that account, and the project's key (for example `FEED`).
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Jira

1. Log in to `https://id.atlassian.com/manage-profile/security/api-tokens`.
2. Select **Create API token**, give it a name, and pick an expiration date (1 to 365 days).
3. Select **Create**, then **Copy to clipboard**. You can't see the token again.
4. Authenticate with Basic auth: your Atlassian account email and the token ([manage API tokens](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/), [basic auth for REST APIs](https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/)).

Tokens expire, so note the date and make a new one before then.

## 2. Pull new issues

Search with JQL using the [enhanced search endpoint](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/), `/rest/api/3/search/jql`. Atlassian marks the older `/rest/api/3/search` as being removed, so don't build on it.

```bash
curl -u "$JIRA_EMAIL:$JIRA_API_TOKEN" -G "https://YOUR_SITE.atlassian.net/rest/api/3/search/jql" \
  --data-urlencode 'jql=project = "FEED" AND updated >= "-60m" ORDER BY updated ASC' \
  --data-urlencode 'fields=summary,description,reporter,created,status,issuetype,priority,labels' \
  --data-urlencode 'maxResults=100'
```

- Pass the response's `nextPageToken` to get the next page. It is missing on the last page.
- JQL dates are read in the time zone set on the account. The code avoids that by asking for a relative time (`"-60m"` is the last 60 minutes).

For each issue, [get comments](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/) with `GET /rest/api/3/issue/ISSUE_ID/comment?orderBy=created`. Comments page with `startAt` and `maxResults`, and the response carries `total`.

Version 3 of the API returns the issue `description` and each comment `body` in [Atlassian Document Format](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/) (ADF): a JSON tree of nodes, where `text` nodes hold the words. The code's `adfToText` walks the tree and joins the text into plain text, one line per paragraph or list item. It leaves out `mention` nodes, because they hold people's names.

Rate limits, per Atlassian: each endpoint allows about 100 GET requests per second per site, and going over returns `429` with a `Retry-After` header. The hourly points quotas apply to Forge, Connect and OAuth apps, not to API token traffic ([rate limiting](https://developer.atlassian.com/cloud/jira/platform/rate-limiting/)).

## 3. Map the fields

| Dimension Labs | Jira | Notes |
|---|---|---|
| `text` | issue `summary` and `description`; comment `body` | Flatten ADF with `adfToText`. Skip comments where `jsdPublic` is `false` (internal comments in a service project) and comments with a `visibility` restriction. |
| `userId` | issue `reporter.accountId` | The Atlassian account id, on every message of the issue. Fall back to the `sessionId`. |
| `incoming` | comment `author` | The issue itself is `true`. A comment is `true` when its author is the reporter or a service desk customer (`accountType` is `customer`); anyone else counts as your team. If people outside your team comment on issues they didn't report, change this rule. |
| `sessionId` | issue `id` | Prefix it, for example `jira-10042`. |
| `conversationId` | issue `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | issue `created`; comment `created` | Convert with `toMillis`. Jira's offsets look like `+0000`, which `toMillis` reads. |
| `platformJson` | `key`, `issuetype.name`, `status.name`, `priority.name`, `labels`; comment `id` | Add any custom field you need to `fields` in the search. |

**Personal data.** Don't send `emailAddress` or `displayName` from the reporter or comment authors. Issues and comments can hold names, emails and account details: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// jira.mjs. Environment: JIRA_SITE (your-site.atlassian.net), JIRA_EMAIL, JIRA_API_TOKEN, JIRA_PROJECT (the project key), DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const base = `https://${process.env.JIRA_SITE}/rest/api/3`;
const auth = 'Basic ' + Buffer.from(`${process.env.JIRA_EMAIL}:${process.env.JIRA_API_TOKEN}`).toString('base64');

// Retries Jira's rate limits (429) and server errors (5xx), honoring Retry-After.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Jira returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Flattens Atlassian Document Format to plain text. Mentions are dropped: they hold people's names.
const INLINE = new Set(['text', 'hardBreak', 'mention', 'emoji', 'inlineCard', 'date', 'status', 'mediaInline']);
export function adfToText(node) {
  if (!node || typeof node !== 'object') return typeof node === 'string' ? node : '';
  if (node.type === 'text') return node.text ?? '';
  if (node.type === 'hardBreak') return '\n';
  if (node.type === 'emoji') return node.attrs?.text ?? '';
  if (node.type === 'inlineCard') return node.attrs?.url ?? '';
  if (node.type === 'mention') return '';
  const kids = node.content ?? [];
  const joined = kids.map(adfToText).join(kids.every((k) => INLINE.has(k.type)) ? '' : '\n');
  return node.type === 'doc' ? joined.replace(/\n{3,}/g, '\n\n').trim() : joined;
}

// The issue and its public comments, as messages.
async function messagesFor(issue) {
  const f = issue.fields;
  const reporter = f.reporter?.accountId;
  const out = [{ text: [f.summary, adfToText(f.description)].filter(Boolean).join('\n\n'), incoming: true, ts: toMillis(f.created) }];
  for (let startAt = 0; ; ) {
    const page = await get(`${base}/issue/${issue.id}/comment?orderBy=created&startAt=${startAt}&maxResults=100`);
    for (const c of page.comments) {
      if (c.jsdPublic === false || c.visibility) continue; // internal or restricted comments
      const a = c.author ?? {};
      out.push({ text: adfToText(c.body), incoming: a.accountId === reporter || a.accountType === 'customer', ts: toMillis(c.created), comment_id: c.id });
    }
    startAt += page.comments.length;
    if (!page.comments.length || startAt >= page.total) break;
  }
  return out;
}

// Sends every issue and public comment written at or after `since` (Unix ms). Returns the new `since`.
export async function syncJira({ since, historical = false }) {
  let newest = since;
  const minutes = Math.ceil((Date.now() - since) / 60000) + 1; // relative time: no time zone to get wrong
  const jql = `project = "${process.env.JIRA_PROJECT}" AND updated >= "-${minutes}m" ORDER BY updated ASC`;
  for (let token = null, first = true; first || token; first = false) {
    const params = new URLSearchParams({ jql, maxResults: '100', fields: 'summary,description,reporter,created,status,issuetype,priority,labels' });
    if (token) params.set('nextPageToken', token);
    const page = await get(`${base}/search/jql?${params}`);
    for (const issue of page.issues) {
      const sessionId = `jira-${issue.id}`;
      const f = issue.fields;
      for (const m of await messagesFor(issue)) {
        if (m.ts < since || !m.text.trim()) continue;
        await sendToDimensionLabs({
          text: m.text,
          userId: f.reporter?.accountId ?? sessionId,
          incoming: m.incoming,
          sessionId,
          conversationId: sessionId,
          dimensionlabs_timestamp: m.ts,
          platformJson: { issue_key: issue.key, issue_type: f.issuetype?.name, status: f.status?.name, priority: f.priority?.name, labels: f.labels, comment_id: m.comment_id },
        }, { historical: historical || m.ts < Date.now() - HOUR });
        newest = Math.max(newest, m.ts);
      }
    }
    token = page.nextPageToken ?? null;
  }
  return newest;
}
```

## 5. Run it

Save the code as `jira.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncJira as sync } from './jira.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every issue and comment written since that date, on issues updated since then, to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new issues and comments to `/track`, and they are enriched automatically every day. A message that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

Each issue costs at least one comment call, so a history load of a busy project takes a while; the code waits whenever Jira asks it to. You can also [upload a file](/data/upload-a-file) of exported issues (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Jira reference

- [Jira Cloud REST API v3](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/)
- [Issue search](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-search/)
- [Issue comments](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issue-comments/)
- [Atlassian Document Format](https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/)
- [Basic auth for REST APIs](https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/)
- [Manage API tokens for your Atlassian account](https://support.atlassian.com/atlassian-account/docs/manage-api-tokens-for-your-atlassian-account/)
- [JQL fields](https://support.atlassian.com/jira-software-cloud/docs/jql-fields/)
- [Rate limiting](https://developer.atlassian.com/cloud/jira/platform/rate-limiting/)

Checked against Atlassian's documentation on 2026-10-26.
