# ๐Ÿงญ `@saptools/jira` **Jira Cloud CLI and typed API that reuse the same OAuth token store as JiraOps.** Use the JiraOps browser login once, then script Jira reads and focused write actions from the terminal without copying tokens between tools. [![npm version](https://img.shields.io/npm/v/@saptools/jira.svg?style=flat&color=CB3837&logo=npm)](https://www.npmjs.com/package/@saptools/jira) [![license](https://img.shields.io/npm/l/@saptools/jira.svg?style=flat&color=blue)](./LICENSE) [![node](https://img.shields.io/node/v/@saptools/jira.svg?style=flat&color=339933&logo=node.js&logoColor=white)](https://nodejs.org) [![types](https://img.shields.io/npm/types/@saptools/jira.svg?style=flat&color=3178C6&logo=typescript&logoColor=white)](https://www.typescriptlang.org) [Install](#-install) โ€ข [Authentication](#-authentication) โ€ข [CLI](#-cli) โ€ข [Security](#-security)
--- ## โœจ Features - ๐Ÿ” **Shared JiraOps token** โ€” reads and refreshes `~/.jira-oauth/tokens.json`, the default `jira-oauth-client` store used by JiraOps. - ๐Ÿชช **Connected identity** โ€” reads the current Jira account profile without exposing its bearer token. - ๐ŸŽซ **Assigned issue list** โ€” uses the same assigned-ticket JQL as JiraOps. - ๐Ÿ“– **Issue details** โ€” returns summary, status, priority, assignee, ADF description text and raw ADF, paginated comments, locally downloaded attachments, and clone-linked issues. - ๐Ÿ“ **Issue content writes** โ€” updates summaries, media-safe descriptions, and ADF comments. - ๐Ÿ›Ÿ **Recoverable comment deletion** โ€” saves a private, durable local backup before deleting one comment. - ๐Ÿ”— **Remote links** โ€” lists Jira remote links such as GitLab MRs, runbooks, or dashboard URLs. - ๐Ÿ”„ **Transitions** โ€” lists available status transitions and applies a selected transition ID. - ๐Ÿ‘ค **Safe assignment** โ€” assigns one issue only after resolving exactly one active issue-assignable Jira account. - โฑ๏ธ **Worklogs** โ€” adds focused time entries with optional ADF text comments and records successful writes in local history. - ๐Ÿงญ **Custom fields** โ€” discovers Jira Cloud custom fields, pins useful display names, and updates editable pinned fields without hard-coded site IDs. - ๐Ÿงฉ **Typed API** โ€” every CLI workflow is available as a TypeScript function. - ๐Ÿงช **Fake-backed E2E** โ€” test coverage validates the real built CLI without calling Atlassian. --- ## ๐Ÿ“ฆ Install ```bash npm install -g @saptools/jira # Or as a project dependency npm install @saptools/jira # pnpm add @saptools/jira ``` > [!NOTE] > Requires **Node.js โ‰ฅ 20** and a Jira Cloud account. This package targets Atlassian Cloud's `api.atlassian.com/ex/jira` API, not Jira Data Center. --- ## ๐Ÿ” Authentication `@saptools/jira` intentionally uses the same token store as JiraOps and `jira-oauth-client`: ```text ~/.jira-oauth/tokens.json ``` If JiraOps already connected successfully, this CLI can read the same stored access token immediately: ```bash jira status jira issues ``` When the token expires, refresh and connect flows need the Atlassian OAuth app credentials in the CLI environment: ```bash export JIRA_CLIENT_ID="your-atlassian-oauth-client-id" export JIRA_CLIENT_SECRET="your-atlassian-oauth-client-secret" jira connect ``` To remove the local shared token file: ```bash jira logout ``` Your Atlassian OAuth app must allow the `jira-oauth-client` callback URL: ```text http://localhost:30129/callback ``` The new REST operations are covered by the existing classic Jira OAuth scopes: `jira whoami` needs `read:jira-user`, issue and attachment reads need `read:jira-work`, and comment deletion needs `write:jira-work`. The default `jira-oauth-client` login also requests `offline_access` so stored tokens can be refreshed. Use a custom token file only when you deliberately do not want to share the JiraOps token: ```bash jira --token-store ./tmp/jira-tokens.json status ``` --- ## ๐Ÿงฐ CLI ### `jira status` Show whether a shared Jira token is present and still usable. ```bash jira status jira status --json ``` ### `jira whoami` Show the connected Jira account without printing or extracting its bearer token: ```bash jira whoami ``` Human output includes the display name, account ID, email availability, and active/inactive status. Jira may omit the email address because of profile privacy settings; `jira whoami --json` keeps the `emailAddress` key and returns `null` in that case. Use the JSON form only when a script needs to parse these fields. ### `jira connect` Run the browser OAuth flow and write tokens to the shared token store. ```bash jira connect jira connect --json ``` ### `jira disconnect` Delete the shared token file. ```bash jira disconnect ``` ### `jira logout` Delete the shared token file. This is equivalent to `jira disconnect`. ```bash jira logout ``` ### `jira token` Print the current access token for scripts. ```bash jira token ``` > [!IMPORTANT] > `jira token` prints a live bearer token. Do not paste it into tickets, logs, commits, shell history captures, or screenshots. ### `jira issues` List assigned, not-done issues ordered by update time. ```bash jira issues jira issues --max 10 jira issues --json ``` ### `jira issue ` Read one issue's detail payload. ```bash jira issue OPS-123 jira issue OPS-123 --json jira issue OPS-123 --no-images jira issue OPS-123 --no-attachments ``` JSON issue details include both `descriptionText` and `descriptionAdf`. `descriptionText` is a plain-text convenience string that preserves the description's block structure: headings, paragraphs, and rules each land on their own line, list items get a `-`/number marker, and each inline `media`/`mediaSingle` node is rendered in place as `[image: ]`. `descriptionAdf` is the raw ADF document when Jira returns a valid document, or `null` when the issue has no valid description ADF. Every comment's `bodyText` is produced the same way. #### Issue Images Inline Jira images in the description or comments are saved to the OS temp directory by default. Downloaded local image metadata is returned in the top-level `images[]` array as `fileUrl`/`filePath` entries. Use `--no-images`, `--image-dir `, `--max-image-bytes `, or `--max-images ` to control local image capture. Image capture defaults to at most 20 images and 10,000,000 bytes per image under `os.tmpdir()/saptools-jira/issue-images//...`. #### Issue Attachments `jira issue ` downloads every attachment type by default, including non-image files such as XML and XLSX. Each successfully saved `attachments[]` entry gains `localPath` and `fileUrl`; a skipped or failed entry keeps its metadata and gains a neutral `downloadError` without aborting the other downloads. Attachment downloads default to at most 20 entries and 10,000,000 bytes per attachment under `os.tmpdir()/saptools-jira/issue-attachments//...`. Control them with: ```bash jira issue OPS-123 --no-attachments jira issue OPS-123 --attachment-dir ./controlled-jira-files jira issue OPS-123 --max-attachments 5 jira issue OPS-123 --max-attachment-bytes 2000000 ``` `--no-attachments` skips only the general attachment list; `--no-images` skips only inline-image capture, and the flags compose independently. If an attachment is also an inline image, the CLI reuses the saved image path and fetches that attachment ID only once. > [!IMPORTANT] > Issue reads now write bounded attachment files locally by default. Use > `--no-attachments` for metadata-only reads, or `--attachment-dir` for a > controlled location. Remove sensitive downloads when they are no longer needed. ### `jira describe ` Print or update one issue's description as raw ADF. Read the current raw ADF without updating Jira: ```bash jira describe OPS-123 --print > description.adf.json jira describe OPS-123 --print --json ``` Default `--print` output is deliberately raw pretty-printed ADF JSON even without `--json`, so shell redirection creates a valid `--adf-file` artifact. `--json` wraps the document as `{ "issueKey": "OPS-123", "description": }`. If the issue has no description, default `--print` exits non-zero instead of writing an invalid empty file; use `--json` when callers need to handle `null`. Update mode still requires exactly one body source: ```bash jira describe OPS-123 --text "Plain text description" jira describe OPS-123 --text-file ./description.txt jira describe OPS-123 --adf-file ./description.adf.json jira describe OPS-123 --text "Follow-up notes" --append jira describe OPS-123 --text "Replace anyway" --force jira describe OPS-123 --adf-file ./description.adf.json --json ``` Plain text is converted to ADF paragraphs. Blank lines create separate paragraphs; single newlines inside a paragraph become ADF `hardBreak` nodes. `--adf-file` reads a complete raw ADF JSON document and sends it after validation. Description replacement is safe by default. If the current description contains ADF media nodes, plain-text replacement is refused unless `--force` is passed. Use `--append` to preserve the current ADF content and append new paragraphs. Use `--adf-file` when a caller needs to provide a full document that already includes media nodes. To edit a complex description with images, fetch the current ADF, change only the relevant text node, and push the complete document back: ```bash jira describe OPS-123 --print > description.adf.json # Edit one {"type":"text","text":"..."} node and leave media/mediaSingle nodes untouched. jira describe OPS-123 --adf-file description.adf.json ``` This preserves embedded images because existing `media.attrs.id` values are carried through unchanged; no media upload or regeneration is needed. Text-only flows (`--text` and `--text-file`) cannot preserve media because flattened text does not contain the media nodes. The read-edit-write flow is not transactional. If the description changes in Jira between `--print` and `--adf-file`, the later write overwrites the current server description. Native local-image inline embedding is not implemented. Jira's attachment upload API returns attachment metadata, but native ADF `media` file nodes require a Media Services ID that the public attachment endpoint does not return reliably. Use raw ADF input for image-preserving or image-bearing descriptions. Use `--no-notify-users` to send `notifyUsers=false` on the Jira update. By default, the CLI leaves Jira's notification behavior unchanged. JSON output: ```json { "issueKey": "OPS-123", "updated": ["description"] } ``` ### `jira summary ` Update one issue's summary after verifying the field is editable on that issue. ```bash jira summary OPS-123 "New issue title" jira summary OPS-123 "New issue title" --json jira summary OPS-123 "New issue title" --no-notify-users ``` JSON output: ```json { "issueKey": "OPS-123", "updated": ["summary"] } ``` ### `jira comment ` Add a comment to an issue. Exactly one body source is required: ```bash jira comment OPS-123 --text "Reviewed the rollout logs." jira comment OPS-123 --text-file ./comment.txt jira comment OPS-123 --adf-file ./comment.adf.json jira comment OPS-123 --text "Reviewed the rollout logs." --json ``` Plain text is converted to ADF the same way as descriptions. `--adf-file` is available for callers that need to supply a complete rich ADF comment body. JSON output: ```json { "issueKey": "OPS-123", "commentId": "40001" } ``` ### `jira comment-delete ` Delete one issue comment only after its full current content has been written and synced to a private local backup: ```bash jira comment-delete OPS-123 10098 ``` There is no backup-skip option. If the comment cannot be fetched or the backup cannot be written, Jira receives no `DELETE` request. If Jira rejects the deletion, the backup remains in place and the command does not retry. Successful human output reports the absolute recovery path; scripted callers can use `jira comment-delete OPS-123 10098 --json`. Backups are cloud-scoped to avoid collisions between Jira sites: ```text ~/.saptools/jira/clouds//comments//.json ``` ### `jira links ` List remote links attached to an issue. ```bash jira links OPS-123 jira links OPS-123 --json ``` ### `jira transitions ` List available status transitions. ```bash jira transitions OPS-123 jira transitions OPS-123 --json ``` ### `jira transition --id ` Apply a transition by ID. ```bash jira transition OPS-123 --id 31 ``` ### `jira assign ` Assign one Jira issue after deterministic assignee resolution: ```bash jira assign OPS-123 --me jira assign OPS-123 --to "Example User" jira assign OPS-123 --account-id "account-id-from-ambiguity" jira assign OPS-123 --to "Example User" --json ``` Exactly one selector is required: `--me`, `--to `, or `--account-id `. The CLI rejects missing, combined, or blank selectors before calling Jira. - `--me` fetches `GET /rest/api/3/myself`, then verifies that active account through the issue-scoped assignable-user search before writing. - `--to` is an approximate Jira display-name query. The CLI searches only users assignable to the target issue with `issueKey=`, `query=`, and `maxResults=1000`. - `--account-id` is the deterministic retry path. It still verifies the account through the same issue-scoped assignable-user endpoint with `accountId=` before assignment. Jira can return broad name matches. The CLI never auto-selects among multiple unresolved candidates. A unique normalized exact display-name match wins over weaker fuzzy matches, and a single fuzzy candidate is accepted only when no exact full-name match exists. Multiple exact display-name matches or multiple fuzzy candidates are ambiguous and no Jira mutation occurs. Human ambiguity output lists the candidate display names and account IDs and recommends retrying with `--account-id`: ```text Multiple active assignable Jira users match "Example"; no assignment was changed. 2 candidates: Example One account-id-1 Example Two account-id-2 Retry with: jira assign OPS-123 --account-id ``` JSON ambiguity is written to stderr with a non-zero exit status: ```json { "error": "ambiguous_assignee", "issueKey": "OPS-123", "query": "Example", "message": "Multiple active assignable Jira users matched; no assignment was changed.", "candidates": [ { "accountId": "account-id-1", "displayName": "Example One" }, { "accountId": "account-id-2", "displayName": "Example Two" } ] } ``` Successful JSON output has no hint footer: ```json { "issueKey": "OPS-123", "assignee": { "accountId": "account-id-1", "displayName": "Example One" }, "resolution": "exact" } ``` Assignment requires Jira Browse Projects and Assign Issues permissions, any applicable issue-security access, and OAuth scopes that allow user lookup and assignment (`read:jira-user` and `write:jira-work` for classic scopes). Jira user search operations are documented around a first-1,000-user search window, so the CLI requests `maxResults=1000`; zero results mean only that no active assignable candidate was returned for that issue and query. ### `jira fields` Discover, cache, pin, and update site-specific Jira custom fields by display name. Field IDs such as `customfield_10101` are Jira-site-specific, so agents should discover and pin names for each connected cloud instead of hard-coding IDs. ```bash jira fields discover jira fields discover --search "custom text" jira fields search "custom text" jira fields pin "Custom text A" jira fields pin "Custom text B" jira fields pinned jira fields unpin "Custom text A" jira fields update OPS-123 --field 'Custom text A=analysis notes' jira fields update OPS-123 --field 'Custom text A=analysis notes' --field-file 'Custom text B=./review.md' ``` `jira fields discover` always refreshes from Jira Cloud and has no `--refresh` flag. `jira fields discover --search ` still fetches and saves the complete refreshed snapshot; the search only filters the terminal output so agents can inspect candidates immediately. `jira fields search ` searches the cached snapshot without calling Jira and fails clearly if discovery has not run. Local custom field metadata is stored under the current user's home directory with Node path handling: ```text ~/.saptools/jira/clouds//fields.json ~/.saptools/jira/clouds//pinned-fields.json ``` The cache stores normalized field metadata only. It never stores access tokens, refresh tokens, Authorization headers, OAuth client secrets, request headers, field values, or raw Jira responses. Pinned fields are cloud/site-specific and persist the resolved Jira field ID internally, but normal pin, unpin, update, and footer workflows use Jira display names only; aliases are not generated or accepted. `jira fields update ` resolves names against `pinned-fields.json`, fetches `editmeta` for that issue, verifies every target field is editable before writing, and then sends a Jira issue field update. Textarea custom fields are sent as Atlassian Document Format; single-line text fields are sent as strings. Success output lists only display names and does not echo field values. After fields are pinned, normal human output includes a display-name-only footer such as: ```text Updatable custom fields: Custom text A, Custom text B. Use: jira fields update --field 'FIELD NAME=value' ``` The footer never includes `customfield_*` IDs, custom numeric IDs, schema details, aliases, or values. It is never appended to `--json`, `jira token`, help, or version output; use global `--no-hints` to suppress it in human output. ### `jira worklog ` Add a worklog entry. ```bash jira worklog OPS-123 --minutes 30 jira worklog OPS-123 --minutes 30 --comment "Reviewed rollout logs" jira worklog OPS-123 --minutes 30 --started "2026-05-01T08:20:00.000+0000" ``` Successful worklog writes are also appended to a local, human-readable Markdown history file under: ```text ~/.saptools/jira/worklog-history/YYYYMM.md ``` The monthly file is chosen from the worklog `started` timestamp, so logging time today for a previous month updates that previous month file. If local history cannot be written after Jira accepts the worklog, the CLI prints a warning and does not retry or undo the Jira write. The history stores only the logged-at timestamp, started timestamp, issue key, minutes, hours, and sanitized comment text; it never stores OAuth tokens, refresh tokens, client secrets, Authorization headers, request headers, or raw Jira responses. ### `jira worklogs` Summarize local worklog history without calling Jira, reading tokens, or requiring a network connection. Missing history files produce zero totals. ```bash jira worklogs --day 2026-05-01 jira worklogs --day 2026-05-01 --json jira worklogs --issue OPS-123 --month 202605 --json jira worklogs --issue OPS-123 --from 2026-05-01 --to 2026-05-31 jira worklogs --month 202605 --group-by day jira worklogs --month 202605 --group-by issue ``` Human output includes total minutes/hours and grouped totals. `--json` returns the parsed local entries plus structured totals for agents and scripts. ### Test API root For deterministic integration tests, point the CLI at a fake Atlassian-compatible API root: ```bash jira --api-root http://127.0.0.1:4010/ex/jira issues --json ``` --- ## ๐Ÿงช Development ```bash pnpm install pnpm --filter @saptools/jira build pnpm --filter @saptools/jira lint pnpm --filter @saptools/jira typecheck pnpm --filter @saptools/jira cspell pnpm --filter @saptools/jira test:unit pnpm --filter @saptools/jira test:e2e ``` E2E tests pre-seed a temp `HOME/.jira-oauth/tokens.json` and run the built `dist/cli.js` against a fake Jira HTTP server. --- ## ๐Ÿ”’ Security - OAuth app credentials come from `JIRA_CLIENT_ID`, `JIRA_CLIENT_SECRET`, or explicit flags. - Access and refresh tokens are stored only in the shared token file, with owner-only permissions when this package writes it. - Jira HTTP errors are reported as neutral messages and do not include response bodies. - Downloaded issue attachments can contain sensitive ticket data. Prefer a controlled `--attachment-dir` and remove files after use. - Comment backups contain the full original comment and remain under the private cloud-scoped `~/.saptools/jira/` tree, including when Jira rejects a delete. - Custom field snapshots and pinned-field configs under `~/.saptools/jira/clouds//` store only normalized metadata, never credentials, Authorization headers, raw Jira responses, or field values. - Do not commit `~/.jira-oauth/tokens.json`, custom token stores, access tokens, refresh tokens, or Authorization headers. --- ## ๐Ÿ‘จโ€๐Ÿ’ป Author **dongtran** โœจ ## ๐Ÿ“„ License MIT --- Made with โค๏ธ to make your work life easier!