- Go 99.4%
- Just 0.6%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
ci/woodpecker/push/build Pipeline was successful
.woodpecker/release.yml |
||
| .just | ||
| .woodpecker | ||
| internal | ||
| .gitignore | ||
| CHANGELOG.md | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| README.md | ||
jira
Minimal CLI for querying Jira Cloud.
Install
just install # builds and copies to ~/.local/bin ($XDG_BIN_HOME)
Or build manually:
go build -o jira .
Configuration
Read from the environment, overridable per-invocation via global flags:
| Env | Flag | Purpose |
|---|---|---|
JIRA_URL |
--url |
Base URL, e.g. https://acme.atlassian.net |
JIRA_USER |
--user |
Account email |
JIRA_TOKEN |
--token |
API token (id.atlassian.com → Security) |
JIRA_PROJECT |
--project |
Default project key for list |
JIRA_TEAM_ID |
--team |
Default team ID for list |
Usage
# Raw JQL
jira search 'assignee = currentUser() AND status != Done'
# Filtered list (uses JIRA_PROJECT / JIRA_TEAM_ID by default)
jira list --assignee me --status "In Progress"
jira list --sprint active
jira list --project FOO --order "created DESC" --limit 100
# Issue details
jira show FOO-123
jira show FOO-123 --no-comments
jira show FOO-123 --json
jira show FOO-123 --web # open in $BROWSER instead of printing
# Issue history (who changed what, when)
jira show FOO-123 --changelog
jira show FOO-123 --changelog-only --field status --field priority
jira show FOO-123 --changelog-only --since 2026-09-01
# Create an issue via $EDITOR template
jira create
jira create --keep # keep the draft file after creation
# Edit fields non-interactively (scriptable, one or many keys)
jira edit --priority "No priority" FOO-1 FOO-2
jira edit --assignee "Jane Doe" FOO-1 # resolves name/email to accountId
jira edit --assignee "" FOO-1 # unassign
jira edit --due "" FOO-1 # clear the due date
jira edit --label-add backend --label-rm triage FOO-1
jira edit --field customfield_10019='"0|i000:"' FOO-1
# With no field flags, open summary/labels/description in $EDITOR
jira edit FOO-123
# Comment on an issue (Markdown body → rich text)
jira comment -m "Chased Norddalen; waiting on their reply. See WORK-680." FOO-1
jira comment -m - FOO-1 < body.md
jira comment --list FOO-1
# Move an issue to a workflow state (optionally commenting in the same call)
jira transition Done FOO-123
jira transition "In Progress" FOO-123
jira transition --comment "Superseded by WORK-680." Rejected FOO-123
# Link two issues (reads left to right)
jira link FOO-1 blocks FOO-2
jira link FOO-1 "is blocked by" FOO-2
jira link FOO-1 "relates to" FOO-2
# Remove a link — same words that made it, or by its ID
jira unlink FOO-1 "is blocked by" FOO-2
jira unlink --id 10234
# Reorder the board — an ordered list (top first) or a block move
printf 'FOO-5\nFOO-3\nFOO-8\n' | jira rank
jira rank --before FOO-3 FOO-8
jira rank --top FOO-9
Commands
search [--limit N] [--fields list] [--json|--jsonl] JQL...raw JQL querylist [--assignee] [--status] [--sprint] [--project] [--team] [--order] [--limit] [--fields] [--json|--jsonl]filtered listshow [--json] [--no-comments] [--web|-w] [--changelog|--changelog-only] [--field F] [--since DATE] KEYissue details;--webopens it in$BROWSER.--jsonreturns{issue, comments}where.issueis the raw REST issue verbatim (id,self,fields, …), so.issue.idgives the numeric idcreate [--keep] [--dry-run] [--json]open$EDITORwith a template, parse, and create the issueedit [flags] KEY...change fields from flags (non-interactive, multi-key), or open one issue in$EDITORwhen given no field flagstransition [--comment TEXT] [--dry-run] [--json] STATE KEYmove an issue to a workflow statecomment [-m TEXT] [--list] [--id ID] [--dry-run] [--json] KEYadd, edit, or list commentslink [--dry-run] [--json] SOURCE RELATION TARGETlink two issues by relationship phraseunlink [--dry-run] [--json] SOURCE RELATION TARGET/unlink --id LINK-IDremove a linkrank [--before|--after KEY|--top|--bottom] [--dry-run] [--json] [KEY...]reorder issues on the boardfilter list [--mine] [--name-prefix P] [--json|--jsonl]/filter show [--json] ID/filter edit [--dry-run] [--jql|--name|--description] IDsaved filterswhoamiprint the authenticated account (id, name, site); exits non-zero on bad credentials
--assignee me and --sprint active are shortcuts for currentUser() and openSprints().
jira filter reads and edits saved filters. filter list pages through every
match (--mine scopes to the authenticated account, --name-prefix to an exact
name prefix); --jsonl emits one raw filter per line, and a private filter is
distinguishable from a shared one. filter edit changes only the fields you pass
and preserves the filter's share and edit permissions by sending back what it
read, reduced to the ids Jira accepts on write, so a JQL-only edit never resets
sharing. A permission it cannot round-trip (a project you cannot see) fails the
edit, --dry-run included, rather than being dropped. A new JQL is validated
before the write, and --dry-run shows old → new and writes nothing.
Jira Cloud does not reject a bad Basic credential on most endpoints — it drops
it and serves the request anonymously with HTTP 200, so a wrong, expired, or
mistyped token yields confidently empty results and misleading 404s rather than
an error. Every command detects this (via the X-Seraph-LoginReason header, at
no extra cost) and fails with an authentication error naming the user and site,
so a bad token can never masquerade as "no matching issues". jira whoami is the
explicit one-line check before a batch of writes.
Jira accepts invalid JQL and answers with an empty result and HTTP 200, so an
unknown field or a typo looks exactly like a query that matched nothing. When a
result comes back empty, search and list parse-check the query and, if it is
invalid, fail with Jira's own error text and a non-zero exit instead of the
empty-set message. A query that matches something is never re-checked (no extra
call). --validate forces the check up front; --no-validate disables it.
search and list paginate through every matching page. --limit unset (or
0) returns every match; a positive --limit that hides further matches prints
a truncation warning on stderr rather than silently capping. --fields takes a
comma-separated field list (custom field IDs pass through raw) or *all for the
complete issue; it defaults to summary,status,assignee. --json wraps the
results as {"issues":[…]}; --jsonl emits one raw issue object per line for
jq -c piping.
The link relation is matched against a link type's outward phrase (blocks),
inward phrase (is blocked by), or type name (Blocks), so the command reads
left to right regardless of which phrase you know. Quote multi-word relations.
jira show displays an issue's parent key and its links.
jira unlink removes a link, named the same way link creates it, or by its
internal --id. Direction is respected: A "is blocked by" B and B blocks A
are the same link and either spelling finds it, but A blocks B will not remove
A is blocked by B. Direction is read from link position
(inwardIssue/outwardIssue), not from the type's verb labels — our Blocks
type has its labels swapped, and position is what matches the board — so show,
link, and unlink all agree with reality regardless of the label config. A triple matching no link fails; a triple matching several
(duplicate links of the same type between the same pair do occur) fails and
lists their IDs rather than guessing. --dry-run names the link and both
endpoints' current statuses — the status is the whole reason a stale blocker
gets removed.
Dry-running writes
Every mutating command takes --dry-run: it resolves and validates everything
(looks up the issue, resolves a transition or link type) but sends nothing,
printing the change instead — the current and proposed value of each field, then
the exact request that would be sent:
$ jira transition --dry-run Done WORK-644
DRY RUN — no changes made
WORK-644 [cmx-imagelab] Drag-drop for logo
status Ready → Done
would POST /rest/api/3/issue/WORK-644/transitions
{"transition":{"id":"31"}}
Validation still runs, so a dry run of an impossible change (an unreachable
transition, an unknown link relation) fails loudly rather than reporting a
change it could not have made. Add --json for the same preview
machine-readably, so a wrapper can diff it without scraping text.
Create template
jira create writes this template to a temp file and opens $EDITOR:
Type: Task # story, bug, task, epic, subtask
Label: # comma-separated, e.g. "backend, infra"
Status: # leave blank for project default
Parent: # parent issue key, e.g. WORK-123
Link: # link to other issues, one bullet each:
# - blocks WORK-2
# - is blocked by WORK-3
Title: # required
Description:
Inline # ... comments are stripped from field values. Lines starting with #
outside the description are ignored. Everything after the Description: line
becomes the issue body verbatim (markdown # headings are preserved).
Status is applied as a post-create transition: the value is matched
case-insensitively against the target state name of an available transition.
If the status can't be reached, the issue is still created and a warning is
printed.
Link takes a bullet list of <relation> <KEY> items (uncomment or add your
own under the Link: header). The relation resolves the same way as the link
command. Links are applied after the issue is created; the same soft-warning
rule as Status applies, so a bad relation or missing target never fails the
create. A single inline Link: blocks WORK-2 also works.
Changelog
jira show --changelog appends the issue's history; --changelog-only prints
just the history. It reads oldest-first so it reads as a narrative, and answers
the two questions current state cannot — who changed a field and when, and when
an issue actually entered its current status (updated is a poor freshness
signal after a bulk import). --field NAME restricts to one field and repeats to
union; --since YYYY-MM-DD filters by entry date. --json passes the raw
histories array through untouched. The history is fetched from the dedicated
paginated endpoint and read in full; if it were ever truncated, that is reported
on stderr.
Editing and transitions
jira edit KEY with no field flags opens the issue's summary, labels, and
description in $EDITOR and saves back only the fields you change — a deleted
field line is left untouched. The description is round-tripped as plain text, so
an unedited description is never rewritten (which would flatten rich formatting).
With field flags it runs non-interactively and accepts several keys at once
(label-wide triage is one command, not sixteen). A failure on one key does not
abort the rest, and the exit code is non-zero if any key failed. --due "",
--parent "" clear/detach. --field id=json is the escape hatch for custom
fields; because Jira can accept such a write with a 2xx yet leave the field
unchanged, the value is read back and a warning is printed if it did not land.
A no-op edit (a field already at the requested value) names the field and issues
no write, so it never churns updated. All of it previews under --dry-run.
Value validation. Constrained fields are checked against the issue's
editmeta before the write and during --dry-run, so an invalid value fails
naming the field and listing what is allowed rather than previewing a change that
would 400. Matching is case-insensitive and resolves to the stored spelling
(--priority high → High), never correcting a near-miss. If editmeta cannot
be read it degrades to a warning, and Jira's own error text is surfaced verbatim
on any rejected write.
Assignee resolution. --assignee takes a display name, email, accountId,
me, or "" to unassign. A name resolves in tiers — exact match on email or
full name beats a prefix beats a substring — so a full name that identifies one
person wins even when others share a substring; only a genuine tie fails, with a
capped, closest-first candidate list. Candidates come from the issue's assignable
users (no service-desk or inactive accounts), and the preview shows the resolved
display name for every input form, including a raw accountId.
jira transition STATE KEY matches STATE case-insensitively against the
target state name of an available transition (the same matching create uses
for Status), and errors listing the reachable states when there is no match.
--comment posts a comment as part of the same transition request, so the two
cannot half-succeed — the backlog rule that a Rejected issue must carry a
reason is then one command.
jira rank reorders issues on the board (the Agile API — a different path base
from the rest of the CLI). With no selector, the keys — arguments, or one per
line on stdin, top first — are chained into exactly that sequence, which is how
a Monday-planning list gets applied. With a selector (--before/--after KEY,
--top, --bottom; exactly one) the keys are placed as a contiguous block, in
the order given, relative to it. --top/--bottom find the board's current end
via JIRA_PROJECT. More than 50 issues are chained across requests, never
truncated. --dry-run shows the resulting position — never a rank string, which
is meaningless to read and is rewritten whenever a neighbour moves.
jira comment bodies are Markdown, converted to ADF: bold, code, links,
and bullet/numbered lists render as rich text, and bare issue keys become
clickable links. -m - reads the body from stdin; with no -m it opens
$EDITOR and an empty buffer aborts. --id ID -m TEXT edits an existing
comment (editing someone else's fails with a clear permission error, not a raw
403). --list prints the issue's comments. The body is never reflowed or
cleaned up — these comments are read by people outside the team.