Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@hameddk/jira-cloud-client

Pure REST API client for Jira Cloud.

  • Search, issues, transitions, project versions, users
  • Auto-pagination for /search/jql and /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-client

Quick 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 concern

Custom 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 — the toolkit does not cache cloudId. Callers must memoize themselves if they want to avoid hitting /oauth/token/accessible-resources once per request.

Why caller-side memoization is important

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.

Recommended pattern: promise-coalesced memoization

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).

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, set autoRetryRateLimit: 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 throws JiraRateLimitError immediately rather than blocking that long.
  • After maxRetries, it throws JiraRateLimitError carrying the last retryAfter and 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

About

Jira Cloud REST API client. Issues, transitions, sprints, fix versions. Pluggable auth via async token resolver, configurable rate-limit retry, ADF + field-shape parsers. Zero dependencies.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages