Pure REST API client for Jira Cloud.
- Search, issues, transitions, project versions, users
- Auto-pagination for
/search/jqland/project/{key}/version - Rate-limit aware with configurable auto-retry (HTTP 429,
Retry-After) - Provider-agnostic auth — caller supplies the access token resolver
- ADF helpers (
adfToPlainText,plainTextToAdf) for issue descriptions - Field-shape parsers for Jira's quirky custom-field values
- Zero dependencies, ESM, Node ≥ 18
Status: 0.1.0 — early. The documented surface is stable.
npm install @hameddk/jira-cloud-clientimport { createJiraCloudClient, listAccessibleResources } from '@hameddk/jira-cloud-client';
// 1. Resolve cloudId once (or pass a string if you already know it).
const resources = await listAccessibleResources(myAccessToken);
const cloudId = resources[0].id;
// 2. Build the client. Pass an async resolver for the access token —
// typically your OAuth client's getValidAccessToken().
const jira = createJiraCloudClient({
cloudId,
getAccessToken: () => oauth.getValidAccessToken(),
options: {
pageSize: 100,
autoRetryRateLimit: true, // see "Rate limiting" below
onRateLimit: (retryAfterSec, attempt) =>
console.warn(`[jira] rate limited, retry ${attempt} in ${retryAfterSec}s`),
},
});
// 3. Use it.
const issues = await jira.searchIssuesAll({
jql: 'project = ABC AND resolved >= -30d',
fields: ['summary', 'status', 'assignee'],
});
await jira.transitionIssue('ABC-1', '21');
const versions = await jira.listProjectVersions('ABC');const jira = createJiraCloudClient({
cloudId, // string OR async resolver
getAccessToken, // () => Promise<string|null>
options: {
baseUrl: 'https://api.atlassian.com',
pageSize: 100,
autoRetryRateLimit: true, // false | true | { maxRetries, maxDelayMs }
onRateLimit: (retryAfterSec, attempt) => {},
fetch: customFetch, // testing only
sleep: customSleep, // testing only
},
});| Method | Returns | Notes |
|---|---|---|
searchIssues({ jql, fields?, maxResults?, nextPageToken? }) |
{ issues, nextPageToken } |
Single page. POST /search/jql. |
searchIssuesAll({ jql, fields?, maxTotal? }) |
JiraIssue[] |
Auto-paginates. |
getIssue(key, { fields?, expand? }) |
JiraIssue |
|
updateIssue(key, { fields }) |
void |
PUT /issue/{key} |
transitionIssue(key, transitionId) |
void |
POST /issue/{key}/transitions |
getIssueTransitions(key) |
{ transitions: [{id,name,to}] } |
Available transitions for the workflow. |
getIssueStatusChangelog(key) |
[{fromStatus,toStatus,date,author}] |
Sorted ascending by date. |
getIssueEditMeta(key) |
raw editmeta body |
Pass-through. |
getIssueAssignee(key) |
JiraUser | null |
Convenience: GET issue with ?fields=assignee. |
getUser(accountId) |
JiraUser |
Email lower-cased. |
searchAssignableUsers({ issueKey, query?, maxResults? }) |
JiraUser[] |
|
listProjectVersions(projectKey) |
ProjectVersion[] |
Auto-paginates. |
listAccessibleResources(accessToken, { baseUrl?, fetch? })
// → AccessibleResource[]
// GET /oauth/token/accessible-resources — discover Cloud IDs the token can reach.These take a raw field value that you have already extracted via your own
field-id lookup. They handle Jira's various shape conventions (number,
string, { value }, { name }, arrays).
import {
coerceNumericField, // → number | null
formatOptionField, // → string | null
parseLexoRankValue, // → string | null
adfToPlainText, // ADF document → plain text
plainTextToAdf, // plain text → minimal ADF
} from '@hameddk/jira-cloud-client';
// Example: pull a story-points custom field
const fieldId = await myAppConfig.get('jira_story_points_field'); // your concern
const points = coerceNumericField(issue.fields[fieldId]); // toolkit's concernCustom field IDs are your concern. The toolkit doesn't know which custom field on your Jira site holds story points, T-shirt size, or LexoRank. Look up the field id (typically from app config) and pass the raw value. This separation keeps the toolkit instance-agnostic.
The toolkit doesn't do OAuth — it expects you to pass a getAccessToken()
resolver returning a valid bearer token (or null if not authenticated).
Typical wiring with @hameddk/oauth-toolkit:
const jira = createJiraCloudClient({
cloudId,
getAccessToken: () => atlassianOAuth.getValidAccessToken(),
});The resolver is called on every request — no internal caching.
Cache yourself if your token-fetch is expensive. If the resolver returns
null, a JiraAuthError is thrown without an HTTP call.
cloudId accepts either a string or an async resolver. The resolver runs on
every request — the toolkit does not cache cloudId. Callers must memoize
themselves if they want to avoid hitting /oauth/token/accessible-resources
once per request.
listAccessibleResources is rate-limited by Atlassian. A multi-page
searchIssuesAll of N pages plus per-issue follow-up calls (changelogs,
attachments, …) will resolve cloudId once per call — for a 3000-issue sync,
that is 3000+ requests to /oauth/token/accessible-resources. Most production
runs will hit a 429 long before completing, and the failure aborts the sync
mid-way.
The cloudId for a given Atlassian account is static for the lifetime of the connection. Resolve once, hand back a shared promise to concurrent callers, and clear the cache only on disconnect/reconnect:
import { createJiraCloudClient, listAccessibleResources } from '@hameddk/jira-cloud-client';
let cachedCloudIdPromise = null;
async function resolveCloudId() {
if (cachedCloudIdPromise) return cachedCloudIdPromise;
cachedCloudIdPromise = (async () => {
const token = await getAccessToken(); // your token resolver
if (!token) throw new Error('Not authenticated');
const list = await listAccessibleResources(token);
if (!list.length) throw new Error('No accessible resources');
return list[0].id; // or pick a specific one
})().catch((err) => {
cachedCloudIdPromise = null; // clear on failure → retry next call
throw err;
});
return cachedCloudIdPromise;
}
// Optional escape hatch — call after disconnect/reconnect so a switched
// Atlassian account doesn't get stuck on the previous site's cloudId.
function resetCloudIdCache() {
cachedCloudIdPromise = null;
}
const jira = createJiraCloudClient({
cloudId: resolveCloudId, // shared, memoized, coalesced
getAccessToken,
});This pattern cuts a 3000-issue sync from 3000+ accessible-resources calls down to exactly one, eliminates the rate-limit-induced sync aborts, and shares a single in-flight promise across concurrent callers (no thundering herd on a cold start).
⚠️ Behavior change vs. raw fetch. WithautoRetryRateLimit: true(the default), this toolkit transparently retries HTTP 429 responses. If your previous code threw on the first 429, callers will now wait and succeed instead. This can mask tokens that fail with 401 before a 429, but does not retry 401 itself. If you depend on observing 429s directly, setautoRetryRateLimit: false.
Three forms:
| Value | Behavior |
|---|---|
true (default) |
Up to 2 retries, max 30s wait per retry. |
false |
No retry. First 429 throws JiraRateLimitError. |
{ maxRetries, maxDelayMs } |
Custom limits. |
When 429 is received:
- The toolkit reads
Retry-After(seconds or HTTP-date) — falls back to 1s. - If the wait would exceed
maxDelayMs, it throwsJiraRateLimitErrorimmediately rather than blocking that long. - After
maxRetries, it throwsJiraRateLimitErrorcarrying the lastretryAfterand provider body. - An optional
onRateLimit(retryAfterSec, attempt)callback fires before each retry — useful for logging. Throwing inside the callback is swallowed; it cannot break the retry loop.
import {
JiraError, // base
JiraConfigError, // bad config / missing required arg
JiraAuthError, // 401, 403, or getAccessToken returned null
JiraNotFoundError, // 404, carries `resource`
JiraRateLimitError, // 429 after retries (or immediately if disabled)
JiraApiError, // other 4xx/5xx, carries `status` + `body`
} from '@hameddk/jira-cloud-client';All errors extend JiraError. The provider's response body is preserved
verbatim where available.
- Doesn't perform OAuth — bring your own token resolver.
- Doesn't persist anything. No DB, no filesystem.
- Doesn't compute cycle-time, lead-time, throughput, or other metrics — that's business logic.
- Doesn't know about your custom field IDs — pass raw values to the parsers.
- Doesn't validate JQL syntax — Jira returns 400 if it's bad.
MIT © 2026 Hamed Sattari