Magrathea Software ← Blog

Field Notes

Lessons from building a Jira bulk operations app on Atlassian Forge

22 July 2026 · Kyle Twogood, Magrathea Software

Earlier this year we shipped Bulk Ops for Jira, a Forge app that runs previewed bulk edits across arbitrarily large JQL result sets. Forge turned out to be a genuinely good fit for this kind of long-running background work, but a handful of platform behaviors cost us real debugging time, including one bug that made it to production. These are the notes we wish we had found before starting.


1. A function-based queue consumer reads event.body, not event.payload

This one shipped as a live bug, so it goes first. With @forge/events you can wire a queue consumer two ways in the manifest: point it at a resolver (with resolver and method keys) or point it directly at a function:

consumer:
  - key: batch-consumer
    queue: bulk-ops-batches
    function: worker-fn

The two styles put the pushed payload in different places. A resolver-based consumer receives it on event.payload. A function-based consumer receives the raw AsyncEvent, and the payload is on event.body. If you read event.payload in a function-based consumer you get undefined, no type error, and no crash at push time. In our case the worker was invoked, immediately threw on the undefined payload, and the platform retried the event forever. From the outside, runs just sat in "queued".

What made it nasty is that it was invisible to unit tests: our engine tests called the inner function with a well-formed event, and the smoke test only asserted the handler existed. The mismatch only exists at the platform boundary, so the only place it could surface was forge logs on a real installation. Two takeaways:

2. Model a long run as a chain of idempotent pages

Forge functions cap out at 900 seconds (timeoutSeconds: 900 in the manifest), and a bulk edit across tens of thousands of issues will not fit in one invocation. The pattern that works: the worker processes one page of issues, pushes the next page as a new queue event, and exits. The queue becomes your scheduler.

The details that make it robust:

Why 50 issues per page? Each page writes one audit chunk (before and after values for every issue) to Forge KVS, and KVS values cap at around 240 KiB. Fifty issues keeps the worst-case chunk comfortably under the cap. Your number will differ; the point is that the page size falls out of the storage limit, not out of the API.

3. Let the queue do your rate-limit backoff

When Jira returns 429 (or a 503 with a Retry-After), the tempting move is to sleep and retry in-process. In a 900-second function, sleeping burns your budget and can still time out. Instead, we re-push the same event (same page token, same sequence) with delayInSeconds set from the Retry-After header, and exit cleanly:

if (throttled) {
  await pushBatch({ runId, pageToken, seq }, throttled.retryAfterSeconds);
  return { ok: true, reason: 'throttled-rescheduled' };
}

The sequence check from lesson 2 makes this free: a rescheduled event is indistinguishable from a redelivery, so no special casing. Two caps to know about: delayInSeconds maxes out at 900, and we also floor it at 1 because a Retry-After: 0 must not become an immediate re-fire loop. Transient 5xx errors are the one thing we do retry in-process, with capped exponential backoff and jitter; throttling always goes back through the queue.

One more distinction that matters on resume: a 4xx when fetching a page (for example a stale page token after a long pause) is permanent, so fail the run with a useful message. Rethrowing it just makes the platform redeliver a poisoned event forever.

4. The JQL editor autocomplete needs a key remap nobody documents

A bulk-edit app lives or dies on issue selection, and Atlassian publishes the actual JQL editor from Jira as @atlaskit/jql-editor with @atlaskit/jql-editor-autocomplete-rest for autocomplete. Getting it working inside Forge Custom UI took three non-obvious steps.

First, the editor requires react-intl; wrap your app in <IntlProvider locale="en"> or it throws at mount.

Second, the autocomplete hook fetches two kinds of data through callbacks you supply (use requestJira from @forge/bridge). The hook expects jqlFields and jqlFunctions on the initial-data response, but the Jira REST endpoint returns visibleFieldNames and visibleFunctionNames. Nothing errors if you skip the remap; field autocomplete is just silently empty:

const getInitialData = async (url) => {
  const data = await (await requestJira(url)).json();
  return {
    jqlFields: data.visibleFieldNames,
    jqlFunctions: data.visibleFunctionNames,
  };
};
const getSuggestions = async (url) => (await requestJira(url)).json();
const autocomplete = useAutocompleteProvider('my-app', getInitialData, getSuggestions);

Third, the editor's built-in "syntax help" link tries to navigate inside the iframe, which the Forge sandbox blocks. Supply onSyntaxHelp and open the docs with router.open() from @forge/bridge instead.

5. Theme support is one line if you do it on day one

Jira ships light, dark, and high-contrast themes, and a Custom UI iframe follows none of them by default. The fix is calling view.theme.enable() before first render, which injects Atlassian's design-token CSS variables into the iframe and keeps them in sync, then styling exclusively with token() from @atlaskit/tokens:

view.theme.enable().then(render, render); // render either way; a theming
                                          // failure should never blank the app

The trap is sequencing. If you build screens with hardcoded colors "for now," dark-mode support becomes an audit of every component later. We now treat theming as a baseline for the first milestone of every app, never a retrofit. Related: Atlaskit components inject inline styles via Emotion, which Forge's default CSP blocks, so you likely need content.styles: ['unsafe-inline'] in the manifest.

6. Smaller sharp edges, quickly

Would we choose Forge again?

Yes. Zero infrastructure, storage and queues included, and running inside Atlassian's trust boundary is a real distribution advantage: the app qualifies for the "Runs on Atlassian" badge and there is no vendor server for a security team to review. The costs are the ones described above: hard limits you must design around rather than configure away, and a few contracts you only learn at the platform boundary. Design the work as small idempotent units connected by the queue and the platform does the rest.

See the result: Bulk Ops for Jira on the Atlassian Marketplace, or read about the user-facing side in how to bulk edit Jira issues with JQL.

Kyle Twogood

Kyle Twogood is the founder of Magrathea Software. He’s been building production software since 1997.