No description
  • Go 99.4%
  • Just 0.6%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Claudia 458c858a87
All checks were successful
ci/woodpecker/push/build Pipeline was successful
chore: sync the files wrangle manages
.woodpecker/release.yml
2026-10-20 21:23:18 +02:00
.just feat: install to the XDG bin dir 2026-08-01 23:42:51 +02:00
.woodpecker chore: sync the files wrangle manages 2026-10-20 21:23:18 +02:00
internal fix: filter edit sends permissions in the shape Jira accepts on write 2026-09-30 15:44:21 +02:00
.gitignore chore: ignore local agent settings 2026-08-01 23:55:36 +02:00
CHANGELOG.md docs: changelog for v1.5.0 2026-10-13 14:27:41 +02:00
go.mod feat: add shell completion command 2026-06-25 11:43:19 +02:00
go.sum feat: add shell completion command 2026-06-25 11:43:19 +02:00
justfile feat: install to the XDG bin dir 2026-08-01 23:42:51 +02:00
main.go feat: add jira filter list/show/edit with sharing preserved 2026-09-30 14:14:57 +02:00
README.md fix: filter edit sends permissions in the shape Jira accepts on write 2026-09-30 15:44:21 +02:00

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 query
  • list [--assignee] [--status] [--sprint] [--project] [--team] [--order] [--limit] [--fields] [--json|--jsonl] filtered list
  • show [--json] [--no-comments] [--web|-w] [--changelog|--changelog-only] [--field F] [--since DATE] KEY issue details; --web opens it in $BROWSER. --json returns {issue, comments} where .issue is the raw REST issue verbatim (id, self, fields, …), so .issue.id gives the numeric id
  • create [--keep] [--dry-run] [--json] open $EDITOR with a template, parse, and create the issue
  • edit [flags] KEY... change fields from flags (non-interactive, multi-key), or open one issue in $EDITOR when given no field flags
  • transition [--comment TEXT] [--dry-run] [--json] STATE KEY move an issue to a workflow state
  • comment [-m TEXT] [--list] [--id ID] [--dry-run] [--json] KEY add, edit, or list comments
  • link [--dry-run] [--json] SOURCE RELATION TARGET link two issues by relationship phrase
  • unlink [--dry-run] [--json] SOURCE RELATION TARGET / unlink --id LINK-ID remove a link
  • rank [--before|--after KEY|--top|--bottom] [--dry-run] [--json] [KEY...] reorder issues on the board
  • filter list [--mine] [--name-prefix P] [--json|--jsonl] / filter show [--json] ID / filter edit [--dry-run] [--jql|--name|--description] ID saved filters
  • whoami print 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.