@hameddk/jira-cloud-client
v0.1.0
Published
Pure REST API client for Jira Cloud. Search, issues, transitions, project versions, and users — with auto-pagination and rate-limit handling. Caller supplies the access token. Zero deps. Node 18+.
Maintainers
Readme
@hameddk/jira-cloud-client
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.
Install
npm install @hameddk/jira-cloud-clientQuick start
import { 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');Public API
Factory
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
},
});Methods
| 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. |
Standalone helpers
listAccessibleResources(accessToken, { baseUrl?, fetch? })
// → AccessibleResource[]
// GET /oauth/token/accessible-resources — discover Cloud IDs the token can reach.Field-shape parsers
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.
Authentication
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 resolution
cloudId accepts either a string or an async resolver. The resolver runs on
every request — there is no internal caching, so cache yourself if the
lookup is expensive (e.g. by calling listAccessibleResources once at app
start and storing the result).
Rate limiting
⚠️ Behavior change vs. raw fetch. With
autoRetryRateLimit: 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.
Errors
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.
What this library does not do
- 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.
License
MIT © 2026 Hamed Sattari
