
Jira Communication
- 354 installs
- 73 repo stars
- Updated August 2, 2026
- netresearch/jira-skill
jira-communication is an agent skill that creates, updates, comments on, and transitions Jira issues so coding agents stay aligned with tickets, acceptance criteria, and sprint context.
About
jira-communication is an agent skill from netresearch/jira-skill that connects coding agents to Jira issue operations during software delivery. It supports creating issues with summaries and descriptions, updating fields, posting implementation comments, linking branches or PR context, and transitioning tickets across workflow states like In Progress, In Review, and Done. Developers reach for jira-communication when agents should mirror engineering work in Jira—opening subtasks from acceptance criteria, documenting blockers inline, or closing stories after merge—without context-switching to the Jira web UI. The skill fits sprint-based teams where ticket state must reflect repo activity and code review outcomes. Typical sessions start from a ticket key, fetch acceptance criteria, implement changes, post progress comments with technical detail, and transition the issue when CI or review gates pass. jira-communication reduces drift between Git activity and sprint boards for backend, frontend, and platform engineers using Jira Cloud or Server APIs through the agent host.
- Issue creation and updates
- Sprint-aware comments
- Acceptance criteria alignment
- Workflow status transitions
Jira Communication by the numbers
- 354 all-time installs (skills.sh)
- +16 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #830 of 3,282 Productivity & Planning skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/netresearch/jira-skill --skill jira-communicationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 354 |
|---|---|
| repo stars | ★ 73 |
| Last updated | August 2, 2026 |
| Repository | netresearch/jira-skill ↗ |
How do agents create and update Jira issues?
Create, update, comment on, and transition Jira issues from the agent so implementation stays tied to tickets, acceptance criteria, and sprint context.
Who is it for?
Software engineers using Jira sprint boards who want agents to file, comment on, and transition issues without leaving the IDE.
Skip if: Teams on Linear, GitHub Issues, or Azure DevOps without Jira, or organizations that forbid agents from writing ticket data via API.
When should I use this skill?
Implementation work must create Jira issues, update acceptance-criteria fields, comment progress, or transition tickets during a sprint.
What you get
Created or updated Jira issues, inline implementation comments, field updates, and workflow transitions reflecting sprint and acceptance-criteria status.
- Created or updated Jira issues
- Implementation comments
- Workflow state transitions
Files
Jira Communication
CLI scripts via uv run, all supporting --help, --json, --quiet, --debug.
Auto-Trigger
On Jira URL or issue key (PROJ-123), pick by intent — each is one call:
| Intent | Tool |
|---|---|
| triage / work on ticket | jira-issue.py work KEY |
| start QA review | jira-issue.py qa KEY |
| QA-fail follow-up | jira-issue.py qa-fail KEY |
| field-only lookup | jira-issue.py get KEY --fields ... |
| change status | jira-issue.py act KEY → jira-transition.py do |
| audit / sibling discovery | jira-qa-gather.py KEY |
Auth issues → jira-setup.py. Anti-pattern: get + comment list on one key — use the matching verb. See references/intent-verbs.md.
Scripts
Under ${CLAUDE_SKILL_DIR}/scripts/{core,workflow,utility}/.
Core: jira-issue.py, jira-search.py, jira-worklog.py, jira-attachment.py, jira-setup.py, jira-validate.py Workflow: jira-create.py, jira-transition.py, jira-comment.py, jira-move.py, jira-sprint.py, jira-board.py, jira-version.py Utility: jira-user.py, jira-fields.py, jira-link.py, jira-weblink.py, jira-worklog-query.py, jira-watchers.py, jira-qa-gather.py
Execution Style
Run directly. Scripts report ✓/✗. Destructive ops: --dry-run. Global flags before subcommand: jira-issue.py --json get PROJ-123.
Basic Usage
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py get PROJ-123
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-search.py query "assignee = currentUser() AND status != Closed" -n 5 -f key,summary,status
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 --assignee me --priority Critical
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py add PROJ-123 "Comment text"
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-transition.py do PROJ-123 "In Progress"
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-worklog.py add PROJ-123 2h --comment "Work done"
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-create.py issue PROJ "Summary" --type Task
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-attachment.py add PROJ-123 screenshot.pngTerminal transitions: always pass--resolution <value>(e.g.Done,Won't do,Duplicate) or the
resolution field stays empty and the ticket appears unresolved. See references/intent-verbs.md.Related Skills
jira-syntax: For descriptions/comments. Jira uses wiki markup, not Markdown.
References
references/jql-quick-reference.md,references/jql-cookbook.md— JQL beyond simple filtersreferences/multi-profile.md— multiple Jira instances,--profilereferences/troubleshooting.md— auth, SSL, 401, 403, connectionreferences/issue-editing.md—--description,--fields-json, reporter, deletes, movesreferences/creation.md—--parent, components, custom fieldsreferences/comments.md— edit, delete, list, markup lint /--forcereferences/worklog.md—--started, date ranges,jira-worklog-query.pyreferences/attachments.md— upload, download (cwd-only), inspectreferences/links.md— issue/web links, instance-specific link typesreferences/agile.md— sprints, boards,board --namereferences/fields-and-users.md— custom field IDs, users, issue typesreferences/watchers.md— watch, subscribe, list watchersreferences/versions.md— fix/affects versions, releases, version CRUDreferences/qa-gather.md— comprehensive audit bundle (siblings, prose URLs)references/intent-verbs.md—work / qa / qa-fail / act, exact transition names
Authentication
Cloud: JIRA_URL + JIRA_USERNAME + JIRA_API_TOKEN. Server/DC: JIRA_URL + JIRA_PERSONAL_TOKEN. Config via ~/.env.jira or ~/.jira/profiles.json.
<!-- Managed by agent: keep sections & order; edit content, not structure. Last updated: 2025-12-12 -->
AGENTS.md — jira-communication
Development guide for maintaining and extending the Jira communication scripts.
Overview
Python CLI scripts using uv run. Each script is standalone with PEP 723 inline dependencies.
Setup & environment
Development requires Python 3.10+ and uv. No virtual environment needed - uv run handles dependencies.
Build & tests
# Test a script works
uv run scripts/core/jira-validate.py --help
# Test against real Jira (need ~/.env.jira configured)
uv run scripts/core/jira-validate.py --verboseCode style & conventions
Script structure:
- Use argparse with subcommands
- Import shared lib:
from lib.client import get_jira_client - PEP 723 header for inline dependencies
- PYTHONPATH manipulation at top (copy from existing scripts)
Output formats: Every script must support --json, --quiet, and default table output via lib/output.py.
Write operations: Destructive operations (delete, move) must include --dry-run flag.
Security & safety
- Never hardcode credentials
- Use
lib/config.pyfor env loading - Test
--dry-runbefore actual writes
PR/commit checklist
- [ ] Script follows existing structure (copy from similar script)
- [ ] All three output formats work (
--json,--quiet, table) - [ ]
--dry-runfor any write operation - [ ]
--helpis descriptive - [ ] Update SKILL.md with new script docs
Good vs. bad examples
Adding a new script:
# ✓ Copy structure from existing script
# ✓ Use lib/ imports
# ✓ Support all output formats
# ✗ Write from scratch without looking at patterns
# ✗ Hardcode auth or skip --dry-runWhen stuck
- Copy structure from
scripts/core/jira-issue.py(good reference) - Check
lib/for shared utilities - Run
--helpon similar scripts
House rules
- Don't read SKILL.md for development - it's user docs
- Test against real Jira before PR
---
Maintaining this file: See root AGENTS.md for convention reference.
[
{
"name": "search_open_bugs_assigned_to_me",
"prompt": "Search for all open bugs in project PROJ assigned to me",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-search\\.py.*query"
},
{
"type": "content",
"pattern": "assignee\\s*=\\s*currentUser\\(\\)"
},
{
"type": "content",
"pattern": "type\\s*=\\s*Bug"
},
{
"type": "content",
"pattern": "project\\s*=\\s*PROJ"
}
]
},
{
"name": "create_bug_report_login_failure",
"prompt": "Create a bug report in project PROJ for a login failure - users get a 500 error when clicking the login button",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-create\\.py"
},
{
"type": "content",
"pattern": "(--type|--issuetype).*(Bug|bug)"
},
{
"type": "content",
"pattern": "--project.*PROJ"
}
]
},
{
"name": "transition_and_comment",
"prompt": "Transition PROJ-123 to In Progress and add a comment saying work has started",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-transition\\.py.*do.*PROJ-123"
},
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-comment\\.py.*add.*PROJ-123"
},
{
"type": "content",
"pattern": "In Progress"
}
]
},
{
"name": "log_work_with_description",
"prompt": "Log 2 hours of work on PROJ-456 with description 'Implemented API endpoint'",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-worklog\\.py.*add.*PROJ-456.*2h"
},
{
"type": "content",
"pattern": "(--comment|--description).*Implemented API endpoint"
}
]
},
{
"name": "add_multiline_comment_via_stdin",
"prompt": "Add this comment to PROJ-789:\n\nh2. Status Update\n\n* Deployment completed\n* Tests passing\n\nThis is multiline Jira wiki markup — use stdin piping to preserve formatting.",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-comment\\.py.*add\\s+PROJ-789\\s+-"
},
{
"type": "content",
"pattern": "(\\bcat\\b|\\|.*jira-comment|\\bstdin\\b|<<)"
},
{
"type": "content",
"pattern": "Status Update"
}
]
},
{
"name": "add_simple_comment_inline",
"prompt": "Add a comment to PROJ-100 saying 'Fixed in commit abc123'",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-comment\\.py.*add.*PROJ-100.*Fixed in commit"
},
{
"type": "content",
"pattern": "Fixed in commit abc123"
}
]
},
{
"name": "read_issue_status_and_assignee",
"prompt": "What's the status and assignee of PROJ-135?",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-issue\\.py.*get\\s+PROJ-135"
},
{
"type": "content",
"pattern": "(status|assignee)"
}
]
},
{
"name": "get_issue_as_json",
"prompt": "Get PROJ-135 details as JSON",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-issue\\.py.*--json.*get\\s+PROJ-135|jira-issue\\.py.*get\\s+PROJ-135.*--json"
},
{
"type": "content",
"pattern": "--json"
}
]
},
{
"name": "update_issue_priority_field",
"prompt": "Change the priority of PROJ-135 to Critical",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-issue\\.py.*update\\s+PROJ-135"
},
{
"type": "content",
"pattern": "--priority.*Critical"
}
]
},
{
"name": "identify_current_jira_user",
"prompt": "Which Jira user am I logged in as?",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-user\\.py.*me"
},
{
"type": "content",
"pattern": "jira-user\\.py"
}
]
},
{
"name": "create_issue_link_blocks",
"prompt": "Link PROJ-100 as blocking PROJ-200 and show me all links on PROJ-100",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-link\\.py.*create"
},
{
"type": "content",
"pattern": "(Blocks|blocks).*PROJ-200|PROJ-100.*PROJ-200"
},
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-link\\.py.*list\\s+PROJ-100"
}
]
},
{
"name": "add_watcher_to_issue",
"prompt": "Add asmith as a watcher on PROJ-135",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-watchers\\.py.*add\\s+PROJ-135\\s+asmith"
},
{
"type": "content",
"pattern": "asmith"
}
]
},
{
"name": "list_unreleased_versions",
"prompt": "List unreleased versions in project PROJ",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-version\\.py.*list\\s+PROJ"
},
{
"type": "content",
"pattern": "(--status.*unreleased|unreleased)"
}
]
},
{
"name": "upload_attachment_to_issue",
"prompt": "Attach the file /tmp/eval-report.txt to PROJ-135",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-attachment\\.py.*add\\s+PROJ-135"
},
{
"type": "content",
"pattern": "/tmp/eval-report\\.txt"
}
]
},
{
"name": "download_all_attachments_from_issue",
"prompt": "Download all attachments from PROJ-135 into ./attachments",
"assertions": [
{
"type": "tool_use",
"tool": "Bash",
"pattern": "jira-attachment\\.py.*download-all\\s+PROJ-135"
},
{
"type": "content",
"pattern": "(\\./)?attachments"
}
]
}
]
Agile — Sprints and Boards
When to load
Load this reference whenever the user wants to list sprints, list boards, move issues between sprints, or identify the active sprint for a board.
Boards
# All boards visible to the authenticated user
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-board.py list
# Filter by project
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-board.py list --project PROJ
# Show only Scrum or Kanban
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-board.py list --type scrumSprints
# Sprints for a specific board (positional BOARD_ID)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-sprint.py list 119
# Filter by state
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-sprint.py list 119 --state active
# The single currently-active sprint for a board
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-sprint.py current 119
# Issues in a sprint (positional SPRINT_ID)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-sprint.py issues 916Assigning an issue to a sprint
The Sprint custom field takes the sprint's integer ID (not its name). Resolve the field's id via jira-fields.py search "sprint" on your instance; it's typically customfield_<N>.
# Substitute the real custom-field id from `jira-fields.py search "sprint"`
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \
--fields-json '{"customfield_SPRINT": 916}'See issue-editing.md for more on --fields-json, and fields-and-users.md for looking up the sprint custom-field ID on other instances.
Scrum vs Kanban
- Scrum boards own named sprints; issues have a Sprint field with an integer ID.
- Kanban boards have no sprints — the Sprint field is always empty;
jira-sprint.py list <KANBAN_BOARD_ID>returns an empty array, not an error.
Attachments — Upload and Download
When to load
Load this reference whenever the user wants to attach a file to an issue, download an attachment, or work with attachment URLs (including any concerns about path traversal, size limits, or SSRF).
Upload
# Simple upload (single file per invocation)
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-attachment.py add PROJ-123 screenshot.png
# Preview (no upload, just show what would be sent)
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-attachment.py add PROJ-123 /tmp/report.pdf --dry-run
# Multiple files — call `add` once per file
for file in a.png b.png c.pdf; do
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-attachment.py add PROJ-123 "$file"
donejira-attachment.py add takes a single FILE_PATH argument (absolute or relative) and only requires that the file exists and is readable. There is no --allow-absolute flag and no cwd-confinement on the upload side — validate paths in the caller if needed.
Download
jira-attachment.py download takes two positional arguments: the attachment URL and the output file path. Find the URL via jira-issue.py get --json (the fields.attachment[].content field carries the download URL).
# Positional: full URL (or /rest/api/2/attachment/content/<id>) + output file
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-attachment.py download \
"https://jira.example.com/rest/api/2/attachment/content/12345" \
./attachments/report.pdfDownload all attachments
To grab every attachment on an issue in one call (no need to harvest URLs first), use download-all. Files are saved under --dir (default cwd) using their original Jira filenames; duplicate names are disambiguated with the attachment id, and a filename that would escape --dir is skipped.
# All attachments into the current directory
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-attachment.py download-all PROJ-123
# Into a specific directory (created if missing, must stay within cwd)
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-attachment.py download-all PROJ-123 --dir ./attachments
# Preview the list without downloading
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-attachment.py download-all PROJ-123 --dry-runSafety guarantees
- Path traversal: output paths are constrained to the current working directory — the script rejects targets that resolve outside cwd.
cdto the target directory before downloading.download-alladditionally strips path components from each Jira-supplied filename and constrains it within--dir.
Don't use raw curl
Fetching /secure/attachment/... URLs with plain curl returns the Jira login page, not the file — attachment downloads need the authenticated session handling this script provides. Always use jira-attachment.py download.
Comments — Edit, Delete, List
When to load
Load this reference whenever the user wants to edit or delete an existing comment, list comments, or needs to get a comment ID for any reason.
List and get IDs
# Pretty list (most recent last)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py list PROJ-123
# JSON list — use this to harvest comment IDs for edit/delete
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py --json list PROJ-123The JSON output has a top-level comments array; each entry has id, author.displayName, body, and updated.
Edit an existing comment
# Full replacement of the body — edits preserve created timestamp, update the "updated" timestamp
# (issue key, comment ID and text are positional arguments)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py edit PROJ-123 594276 "Corrected text"Jira appends an "edited" marker in the UI automatically.
Delete a comment
# Preview
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py delete PROJ-123 594276 --dry-run
# Real delete
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py delete PROJ-123 594276Deleting someone else's comment requires the Delete All Comments permission.
Multi-line comments
jira-comment.py add takes the body as a positional argument. Pass - to read the body from stdin, which pairs naturally with a HEREDOC or a file:
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py add PROJ-123 - <<'EOF'
h3. Progress
Deployed to staging, see https://staging.example.com/.
EOF
# Or from a file
cat comment.txt | uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py add PROJ-123 -Comments use Jira wiki markup — see the jira-syntax skill for formatting.
Markup lint
add and edit lint the body before posting: inline block tags ({code}, {noformat}, {quote}, {panel} are block-level — a tag with other text on the same line opens a block mid-prose) and unbalanced tag counts abort with an error. Escape literal mentions as \{code\}. Override with --force (findings are then printed as warnings).
Issue Creation — Advanced
When to load
Load this reference whenever the user wants to create a sub-task (--parent), set a custom reporter, attach components, or pass custom-field values on create via --fields-json.
Sub-tasks via --parent
# --type auto-resolves to the right sub-task issue type for the parent's project
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-create.py issue PROJ "Fix flaky test" \
--type Bug --parent PROJ-100When --parent is provided, jira-create.py resolves the sub-task issue type from the project's issue-type catalog (matching on name, case-insensitive). The parent issue itself is not fetched — resolution is project-scoped: an exact match on the requested type wins, otherwise a substring match against sub-task names (e.g., Task → Sub: Task), otherwise the sole sub-task type if only one exists.
Custom reporter
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-create.py issue PROJ "User-reported bug" \
--type Bug --reporter jane.doeValue is the accountId on Cloud, the username on Server/DC. Resolve via jira-user.py search (see fields-and-users.md).
Components
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-create.py issue PROJ "Summary" \
--type Task --components "Backend,API"Components must already exist on the project.
Custom fields on create
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-create.py issue PROJ "Summary" \
--type Task \
--fields-json '{"customfield_SPRINT": 916, "customfield_EPIC": "PROJ-1940"}'Sprint ID is an integer, not an array. Epic Link is the epic's issue key as a string.
Combining flags
--fields-json wins over typed flags (--assignee, --priority, --labels, --reporter, --components) when the same field is set in both — the script merges the JSON payload onto the typed-flag payload (fields.update(extra_fields)). Use typed flags for the fields the CLI exposes directly, and reach for --fields-json only for the long tail.
Fields and Users — Reference Data Lookup
When to load
Load this reference whenever the user needs to: look up a custom field ID, list issue types for a project, search for a Jira user, or resolve a username/accountId for use as a reporter, assignee, or watcher value.
Users
# Resolve a specific identifier — prints the canonical record
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-user.py get john.doe
# Free-text search (by display name or email fragment)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-user.py search "doreen"
# The current authenticated user (what `--assignee me` resolves to)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-user.py meUseful when --assignee, --reporter, or --user rejects a value: search returns the canonical username (Server/DC) or accountId (Cloud) the API expects.
`[~mention]` in comments needs the canonical username, not a guess. The mention token resolves on the account's name/key — which can differ from both the display name and the email local-part (renamed accounts are common: e.g. display "Jane Doe", email jane.doe@…, but name=jane.smith after a rename). Guessing from the display name or email silently produces a non-notifying mention. Resolve it first — jira-user.py search "<display name>", or read the name field of an existing comment's author — then write [~<name>]. If someone says "your mention pinged the wrong/no user", this is why. (Jira Cloud uses [~accountId:<accountId>] instead of [~username]; resolve the accountId the same way and use that form.)
Custom fields
# Search field metadata by name fragment
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-fields.py search "sprint"
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-fields.py search "epic"
# Dump all fields as JSON (for grep/jq pipelines)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-fields.py --json search ""The key you need for --fields-json is the id (e.g. customfield_<N>) — not the human name.
Issue types per project
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-fields.py types PROJPrints every issue type the project accepts, including sub-task types. Issue type names are case-sensitive on create (jira-create.py --type).
Common custom-field shapes (IDs vary per instance)
| Field | Type | Notes |
|---|---|---|
| Sprint | integer | Sprint ID, not name |
| Epic Link | string | Epic issue key, e.g. "PROJ-1940" |
| UAT / Test instructions | text | QA hand-off notes |
Always confirm the id with jira-fields.py search on the target instance — custom-field numbering is not portable.
Intent verbs
jira-issue.py work / qa / qa-fail / act — single-call context bundles for the four common intents.
When to load
Whenever you have a Jira issue key and need more than just meta. Each verb composes the right bundle for one intent. Empirically replaces 3–6 separate calls.
The four verbs
jira-issue.py work KEY # description + all comments + attachments + links
jira-issue.py qa KEY # description + handover bundle (comments around INTO_QA transition)
jira-issue.py qa-fail KEY # description + reviewer rejection + implementer scope context
jira-issue.py act KEY # meta + available transitionsjira-issue.py get KEY is unchanged — it still prints the full issue (description, attachments, links) by default. Use --fields summary,status,assignee,… for a meta-only lookup.
QA-handover heuristic (qa verb)
The handover comment is not always written after the transition. Empirical sample (10 tickets, 41 transitions): 80% of handover comments come before the transition click.
The verb finds the most recent INTO_QA transition (by classification, see below) and includes:
1. All comments by the transition author in [T_prev, min(T_next, T_transition + 1h)] — captures the handover whether written before or after the click. T_prev and T_next bracket the transition between adjacent status changes. 2. All comments by any author in [T_transition, T_next) — the QA discussion that follows.
Deduplicated, chronologically sorted. Fallback if no INTO_QA in changelog: last 5 comments + warning.
QA-fail heuristic (qa-fail verb)
Symmetric to qa:
1. Find the most recent REJECT transition. 2. Include all comments by the reviewer (transition author) in [T_prev_into_qa, T_transition + 1h]. 3. Include all comments by any author in [T_transition, T_next). 4. Include all comments by the implementer (= author of the most recent INTO_QA before the reject) in [T_prev_into_qa - 1h, T_transition] — this captures the implementer's scope/clarification context that the rejection is reacting to. The 1h backward extension catches handover comments written just before the INTO_QA click.
Fallback if no REJECT in changelog: last 5 comments + warning.
Status-set classification
Transitions are classified using three configurable status sets:
| Set | Default | Meaning |
|---|---|---|
qa_status_names | QA, Review, In Review, Code Review, Ready for QA, QA2, UAT, Acceptance, Testing | Where the work goes for review |
working_status_names | In Progress, Open, Reopened, To Do, In Development, Backlog, QA failed | Where rejected work lands. Note: QA failed is in this set, not qa, because it's functionally a reject-target (review verdict: send back to dev), not a review stage. |
resolved_status_names | Closed, Resolved, Done, Won't Fix, Cancelled | Terminal states |
A transition is classified as:
into_qa—from ∉ qa AND to ∈ qa(handover)reject—from ∈ qa AND to ∈ working(fail)forward—from ∈ qa AND to ∈ qa AND from ≠ to(multi-stage progression:QA→QA2,Review→UAT,QA→Acceptance— NOT a fail)resolved—to ∈ resolved— always pass `--resolution <value>` when executing this transition (see below)out—from ∈ qa AND to ∉ qa(uncategorised QA exit)other— neither side touches QA
Forward-progression detection is what lets a multi-stage QA workflow (Review → UAT → Acceptance → Closed) work identically to a single-stage one without code changes.
Resolution field on terminal transitions
When a transition lands in a resolved status, Jira stores two separate things: the status (visible in the badge) and the resolution (the green checkmark, JQL resolution is not EMPTY). The transition API sets the status but leaves the resolution field empty unless you pass it explicitly. An empty resolution means the ticket appears unresolved in filters and dashboards even though the status reads "Resolved".
Always pass --resolution with the value that matches the outcome:
| Outcome | --resolution value |
|---|---|
| Work completed as planned | Done |
| Decided not to do | Won't do |
| Same issue already exists | Duplicate |
| Bug could not be reproduced | Cannot Reproduce |
| Request rejected / out of scope | Declined |
| No longer relevant | Obsolete |
jira-transition.py do PROJ-123 "Resolved" --resolution Done
jira-transition.py do PROJ-123 "Resolved" --resolution "Won't do"
jira-transition.py do PROJ-123 "Resolved" --resolution DuplicateAvailable resolution names vary by Jira instance. Query yours with:
curl -s -H "Authorization: Bearer $JIRA_PERSONAL_TOKEN" "$JIRA_URL/rest/api/2/resolution" \
| python3 -c "import sys,json; [print(r['name']) for r in json.load(sys.stdin)]"Walking a multi-stage workflow (path)
jira-transition.py do performs one transition. Workflows with intermediate gates (e.g. QA → UAT Stage → Ready for deployment → Resolved → Closed) otherwise need one list + one do per stage — closing a ticket deep in such a workflow is 4+ round-trips of discovering the next status by hand.
path collapses that into one call: it runs the list → pick → do loop internally, walking from the current status to a target.
jira-transition.py path PROJ-123 Closed --resolution Done # walk all the way to Closed
jira-transition.py path PROJ-123 "Ready for deployment" # walk to an intermediate gate
jira-transition.py path PROJ-123 Closed --dry-run # preview the first stepIt is a greedy walk, not a graph search: the Jira API only exposes the transitions available from the issue's current status, so path cannot see the whole workflow ahead of time. At each step it takes the target if directly reachable, otherwise the single non-backward transition (transitions whose name matches reopen/cancel/reject/decline/abort/back, or which lead to an already-visited status, are treated as backward). If a step offers several forward options it stops and lists them rather than guess — pick one with do and re-run. --resolution/--comment apply only to the final step; --max-steps (default 10) caps the walk. Because it cannot look ahead, --dry-run shows only the first planned step.
Configuring status sets per Jira instance
Per profile in ~/.jira/profiles.json:
{
"profiles": {
"myinstance": {
"url": "https://jira.example.com",
"token": "...",
"qa_status_names": ["Review", "UAT", "Acceptance"],
"working_status_names": ["In Progress", "Backlog", "Reject"],
"resolved_status_names": ["Done", "Cancelled"]
}
}
}Or via env vars (comma-separated):
JIRA_QA_STATUS_NAMES="Review,UAT,Acceptance"
JIRA_WORKING_STATUS_NAMES="In Progress,Backlog,Reject"
JIRA_RESOLVED_STATUS_NAMES="Done,Cancelled"Output formats
All verbs support the standard global flags:
- (default) Human-readable text bundle
--jsonStructured payload (commentsis always a list of comment dicts; verb-specific keys likereject_transition,handover_transition,implementerfor context)--quietIssue key only (after successful fetch — validates connectivity)
work, qa, qa-fail also accept --truncate N to cap description and per-comment body length. act has no body content so the flag is omitted there.
Example: NRS-4412-style QA-fail follow-up
The motivating case: "what did Björn reject, and what was Sebastian's scope context?"
Before (6 calls): jira-issue get, jira-comment list, jira-comment list | tail, jira-comment list | head, etc.
After (1 call):
jira-issue.py qa-fail NRS-4412Returns: description + Sebastian's scope-setting handover comment + Björn's full AC review with rejection + Sebastian's response + subsequent resolution. Chronologically sorted, ready to read.
Transition names are exact strings
jira-transition.py do KEY "<name>" matches the transition name verbatim — including emoji prefixes some instances configure (e.g. ✅ Resolve, ❌ QA failed). On mismatch the error lists the available names; copy the wanted one exactly as printed. jira-issue.py act KEY shows them up front.
Issue Editing — Advanced
When to load
Load this reference whenever the user wants to set --fields-json, set a custom --reporter, delete an issue (especially with sub-tasks), attempt an unsupported cross-project move via CLI (see below), or change any field that is not assignee, priority, or labels.
--description for plain description edits
For a plain description rewrite, use the typed flag — it avoids JSON-escaping the body:
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \
--description "Rewritten description"
# Pipe a longer body from a file
cat body.txt | uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 --description -The body is Jira wiki markup (see the jira-syntax skill).
--fields-json for custom fields and structured payloads
jira-issue.py update accepts a raw JSON object to set any field the Jira REST API exposes — reach for it when the typed flags don't cover the field:
# Custom fields (Sprint ID as integer, Epic Link as issue key)
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \
--fields-json '{"customfield_SPRINT": 916, "customfield_EPIC": "PROJ-1940"}'
# Combine with typed flags — `--fields-json` wins on conflict
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \
--priority Critical \
--fields-json '{"labels": ["review", "urgent"]}'The script merges --fields-json onto the typed-flag payload (update_fields.update(extra_fields)), so any key present in both is taken from --fields-json. Use typed flags for fields the CLI exposes directly, and reach for --fields-json only for the long tail.
Look up custom field IDs with jira-fields.py — see fields-and-users.md.
Labels: replace vs incremental updates
jira-issue.py update supports three modes:
--labels a,b,creplaces the full label set.--add-label/--remove-labelincrementally update labels without wiping unrelated tags.- Do not combine
--labelswith--add-label/--remove-labelin one invocation.
Each --add-label / --remove-label may be repeated and may contain comma-separated values. Matching for removals is case-insensitive, and additions dedupe case-insensitively while preserving the casing already stored in Jira when possible.
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \
--add-label backend --add-label urgent,frontend --remove-label staleSetting a custom reporter
jira-issue.py update has no --reporter flag; the reporter on an existing issue is changed through --fields-json:
# Cloud (accountId) or Server/DC (name) — both go through --fields-json
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \
--fields-json '{"reporter": {"name": "jane.doe"}}'On Jira Cloud, use {"reporter": {"accountId": "..."}} instead. The create-time shortcut is the typed --reporter flag on jira-create.py — see creation.md.
Deleting issues
# Always preview first
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py delete PROJ-123 --dry-run
# Real delete
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py delete PROJ-123
# Parent with sub-tasks — the API rejects it unless you opt in
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py delete PROJ-100 --delete-subtasks--delete-subtasks cascades the delete. The script refuses without it when sub-tasks exist.
Moving an issue between projects
Cross-project moves are not implemented in jira-move.py because some Jira Server/DC versions accept project edits via the standard issue endpoint without actually moving the issue (silent partial updates / corruption risk). The command refuses cross-project targets for both real execution and --dry-run.
Use the Jira UI Move action (or a bulk-move workflow your admins provide) for cross-project relocation.
Within the same project, jira-move.py can change issue type:
# Preview a same-project type change
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-move.py issue PROJ-100 PROJ --issue-type Task --dry-run
# Execute the type change (issue key stays the same)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-move.py issue PROJ-100 PROJ --issue-type TaskJQL Cookbook — Translating Natural-Language Queries
When to load
Load this reference whenever the user phrases a search request in natural language ("show me all stale bugs", "what have I been working on this sprint") and you need to decide which JQL expression is safe for which phrasing.
How to turn questions like "all my open bugs with no activity in 2 weeks" into safe, portable JQL — and which built-in scripts help with the fuzzy parts (status names, usernames, board names).
This doc complements jql-quick-reference.md, which covers JQL syntax. This one covers reasoning: which JQL expression is safe for which natural-language term, and what to do when the user's phrasing is ambiguous.
---
Fuzzy term → safe JQL
| User says… | Safe JQL | Notes |
|---|---|---|
| "open" / "not done" | statusCategory != Done | Prefer `statusCategory` over enumerating statuses — workflow-agnostic. |
| "done" / "closed" | statusCategory = Done | Same reason. |
| "in progress" (generic) | statusCategory = "In Progress" | The category name, not a status name. |
| "not started" | statusCategory = "To Do" | Covers "Open", "To Do", "Reopened" across workflows. |
| "unresolved" | resolution is EMPTY | Orthogonal to status; some workflows close issues without setting resolution. |
| "my" / "mine" | assignee = currentUser() | Use reporter = currentUser() for "I reported", watcher = currentUser() for "I'm watching". |
| "no activity in N days" | updated < -Nd | updated covers any field change. |
| "stale in status" | See "stale" section below | updated is not sufficient — transitions can be older than last edit. |
| "recently updated" | updated >= -7d | Ask which "recent" means if unsure. |
| "recently created" | created >= -7d | Different from "updated"! |
| "urgent" | priority in (Highest, High) | Priority names are instance-configurable; verify with jira-fields.py. |
| "bug" | issuetype = Bug | ⚠️ Localized instances may use "Fehler" / "Defect". Enumerate issuetypes to verify. |
| "blocked" | ambiguous — see "blocked" section | Could be status, "Flagged" field, or is blocked by link. |
| "current sprint" | sprint in openSprints() | Requires Jira Software (Agile). |
| "backlog" | sprint is EMPTY | In Agile projects. |
| "due this week" | duedate >= startOfWeek() AND duedate <= endOfWeek() | startOfWeek() / endOfWeek() are JQL functions. |
---
Status ambiguity — the #1 gotcha
Jira distinguishes status (workflow-specific) from statusCategory (instance-wide). The three categories are always:
"To Do"— issues not started"In Progress"— issues actively being workedDone— finished issues
Rule of thumb: prefer `statusCategory` over `status` whenever the user's wording is a category-level concept ("open", "done", "active").
| Natural phrasing | Wrong (brittle) | Right (portable) |
|---|---|---|
| "open issues" | status in (Open, "To Do", Reopened) | statusCategory != Done |
| "finished issues" | status = Closed | statusCategory = Done |
| "anything actively being worked on" | status = "In Progress" | statusCategory = "In Progress" |
Reach for a specific status = "X" only when the user names a concrete workflow step ("Code Review", "Staging Tested", "Awaiting QA").
---
"Blocked" — three distinct meanings
| Meaning | JQL |
|---|---|
| Status called "Blocked" | status = Blocked (workflow-dependent) |
| Jira Agile "Flagged" field | "Flagged" is not EMPTY |
Has an incoming is blocked by link | issueFunction in hasLinks("is blocked by") (ScriptRunner) |
When the user says "what's blocked?" without more context, start with the Flagged interpretation on Agile projects, and confirm the intent.
---
"Stale" — update vs. transition
If the user says "issues stale in Review for >14 days", plain updated < -14d is not right — a comment or description edit also updates updated. Two options:
1. Approximation (pure JQL): status = Review AND updated < -14d — catches most cases, but misses issues that had recent edits while still stuck in Review. 2. Exact (needs changelog): use jira-issue time-in-status <KEY> --status Review on each candidate to get the true per-status duration.
Document the limitation when presenting the result.
---
Resolution helpers — point the user here
Most fuzzy terms can be resolved before building JQL:
| What to resolve | Helper |
|---|---|
| Status name ("review" → "In Review") | lib.client.resolve_status(client, "review") — case-insensitive, substring, errors on ambiguity. |
| Username / display name ("John" → accountId / username) | lib.client.resolve_assignee(client, "John") or jira-user.py search "John". |
Custom field name ("Epic Link" → customfield_10014) | jira-fields.py search "Epic Link". |
| Board by name | jira-board.py list --name "Lithium" (server-side partial match). |
| Issue type canonical name | jira-fields.py types PROJ (per-project types incl. localized names). |
| Priority / resolution list | GET /rest/api/2/priority, GET /rest/api/2/resolution (use atlassian-python-api's generic .get()). |
When a resolver returns an ambiguous result, surface the candidates and ask the user — don't silently pick the first match.
---
Worked example 1: "all bug issues open for more than 2 weeks"
Step 1 — parse the terms:
- "bug" →
issuetype = Bug(⚠️ verify per instance) - "open" →
statusCategory != Done - "for more than 2 weeks" → ambiguous; three interpretations:
- A: no activity for 14+ days →
updated < -14d - B: existed 14+ days →
created < -14d - C: stuck in current status 14+ days → needs
time-in-status,
not pure JQL
Step 2 — pick the most common interpretation: A.
Step 3 — assemble:
issuetype = Bug AND statusCategory != Done AND updated < -14dStep 4 — surface the assumption:
"I interpreted '2 weeks open' as 'no activity for 14+ days'.
If you meant created 14+ days ago or *stuck in one status for 14+
days*, say so and I'll rerun."
---
Worked example 2: "all my issues with no activity past 2 weeks"
- "my" →
assignee = currentUser() - "no activity" →
updated - "past 2 weeks" →
< -14d
assignee = currentUser() AND updated < -14dNo resolvers needed. No ambiguity worth surfacing.
---
Worked example 3: "how long has PROJ-123 been in Review?"
Not a JQL question — use the changelog:
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py \
time-in-status PROJ-123 --status review--status review resolves via resolve_status(); matches "In Review" or "Code Review" unambiguously on most instances.
---
Ambiguity protocol
When translating, Claude should:
1. Translate unambiguous mappings directly — "my" → currentUser(), "open" → statusCategory != Done. 2. Resolve instance-specific terms before building JQL — run resolve_status / jira-user search when the term names a status or person. 3. Pick a sensible default for vague time terms — "recently" → 7d, "stale" → 14d, "long-standing" → 30d — and state the assumption in the response. 4. On resolver ambiguity, ask — never silently pick the first match. Surface the candidates. 5. When no pure-JQL expression is accurate, say so — e.g., "stale in Review" strictly needs time-in-status; JQL alone is an approximation.
---
What's deliberately not here
- Hardcoded per-workflow shortcuts like
status = "Code Review".
Status names vary per instance. Resolve first.
- Saved-query templates. Users who want named saved JQLs can use
Jira's built-in Filters (web UI) — they're reusable across this skill and Jira itself.
- Priority / resolution name lists. These are instance-configurable;
fetch them at runtime via the REST API.
JQL Quick Reference
When to load
Load this reference whenever a JQL query goes beyond the inline SKILL.md examples — any use of AND/OR combinators, historical operators (WAS, CHANGED), functions (currentUser(), startOfWeek()), or unfamiliar field names.
Common JQL patterns for jira-search.py query "<JQL>".
Sorting (ORDER BY)
Two equivalent forms — pick one, never both (the script rejects mixing them):
# Embedded in the JQL string
jira-search query "project = PROJ AND status = Open ORDER BY updated DESC"
# Via the --order-by flag (repeatable for multi-key sorts)
jira-search query "project = PROJ AND status = Open" --order-by "updated DESC"
jira-search query "project = PROJ" \
--order-by "priority DESC" --order-by "created ASC"Both forms support the same sort keys (any indexed Jira field) and the same ASC / DESC direction modifiers. The flag form is friendlier when JQL is composed programmatically and the base query should stay untouched.
Operators
Comparison
| Operator | Example | Notes |
|---|---|---|
= | status = "In Progress" | Exact match |
!= | status != Done | Not equal |
> | votes > 4 | Greater than (dates, versions, numbers) |
>= | duedate >= "2024-01-01" | Greater than or equal |
< | priority < High | Less than |
<= | updated <= -4w | Less than or equal |
Text Search
| Operator | Example | Notes |
|---|---|---|
~ | summary ~ "login" | Contains (fuzzy match) |
!~ | summary !~ "test" | Does not contain |
List & Null
| Operator | Example | Notes |
|---|---|---|
IN | status IN (Open, "In Progress") | Multiple values |
NOT IN | priority NOT IN (Low, Lowest) | Exclude values |
IS EMPTY | assignee IS EMPTY | Field has no value |
IS NOT EMPTY | fixVersion IS NOT EMPTY | Field has value |
Historical
| Operator | Example | Notes |
|---|---|---|
WAS | assignee WAS "john" | Previous value |
WAS IN | status WAS IN (Open, "To Do") | Previous in list |
WAS NOT | status WAS NOT Done | Was never value |
CHANGED | status CHANGED | Value was modified |
CHANGED supports predicates: FROM, TO, BY, DURING, BEFORE, AFTER, ON
status CHANGED FROM "Open" TO "In Progress" BY currentUser() AFTER -7dFunctions
User Functions
| Function | Description |
|---|---|
currentUser() | Logged-in user |
membersOf("group") | Members of a group |
Date Functions
| Function | Description |
|---|---|
now() | Current timestamp |
startOfDay() | Midnight today |
startOfWeek() | Start of current week |
startOfMonth() | First of current month |
startOfYear() | January 1st current year |
endOfDay() | End of today (23:59:59) |
endOfWeek() | End of current week |
endOfMonth() | Last day of current month |
endOfYear() | December 31st current year |
Date offsets: startOfDay(-1) = yesterday, startOfWeek(1) = next week
Relative Dates
| Format | Example | Description |
|---|---|---|
-Nd | -7d | N days ago |
-Nw | -2w | N weeks ago |
-Nm | -1m | N months ago |
"YYYY-MM-DD" | "2024-01-15" | Specific date |
Sprint Functions
| Function | Description |
|---|---|
openSprints() | Active sprints |
closedSprints() | Completed sprints |
futureSprints() | Planned sprints |
Version Functions
| Function | Description |
|---|---|
releasedVersions() | Released versions |
unreleasedVersions() | Unreleased versions |
latestReleasedVersion() | Most recent release |
Common Queries
By Assignment
assignee = currentUser()
assignee = "john.doe"
assignee IS EMPTY
assignee IN membersOf("developers")By Status
status = "In Progress"
status IN (Open, "To Do", "In Progress")
status != Done
status WAS "Open"
status CHANGED FROM "Open" TO "In Progress"By Date
created >= -7d
updated >= startOfWeek()
due <= endOfMonth()
resolved >= "2024-01-01"
created >= startOfMonth(-1) AND created < startOfMonth()By Sprint
sprint IN openSprints()
sprint IN closedSprints()
sprint = "Sprint 42"
sprint IS EMPTYBy Text
text ~ "error message"
summary ~ "login bug"
description ~ "timeout"
comment ~ "workaround"Combining Conditions
project = PROJ AND status = Open
priority = High OR priority = Highest
project = PROJ AND (status = Open OR status = "In Progress") AND assignee = currentUser()
NOT status = Done
project = PROJ ORDER BY priority DESC, created ASCKeywords
| Keyword | Usage |
|---|---|
AND | Both conditions must match |
OR | Either condition matches |
NOT | Negate a condition |
EMPTY | Alias for null/no value |
NULL | Alias for empty/no value |
ORDER BY | Sort results (ASC or DESC) |
Quoting Rules
Must quote values containing:
- Spaces:
project = "My Project" - Special characters:
summary ~ "error@host" - Reserved words used as values:
labels = "AND"
No quotes needed for:
- Single words:
status = Open - Project keys:
project = PROJ - Function calls:
assignee = currentUser()
Cloud vs Server/DC Differences
- User references: Cloud uses
accountId(e.g.assignee = "5b10ac8d82e05b22cc7d4ef5"), Server/DC usesusername(e.g.assignee = "john.doe"). ThecurrentUser()function works on both. - Functions like
currentUser(),membersOf(), date functions, and sprint functions work on both platforms.
Sources
Cloud:
Server/Data Center:
Links — Issue-to-Issue and Web Links
When to load
Load this reference whenever the user wants to create, list, or delete a link between two issues (jira-link.py), or a web link from an issue to an external URL (jira-weblink.py).
⚠️ Direction rule (read this before create)
jira-link.py create FROM TO --type X creates the link such that `TO` is the source/active actor (uses the link type's outward verb) and `FROM` is the destination/passive recipient (uses the inward verb).>
Mnemonic: *TO is the agent, FROM is the patient.*
Read the call as: "on FROM, record that TO does X to it."
This matches Atlassian's REST API convention — but watch the field-name trap: in the stored link object, `inwardIssue` holds the source (active actor with the outward verb) and `outwardIssue` holds the destination (passive recipient with the inward verb). The names seem to imply the opposite; they don't. Verify after every create by reading the success sentence. The --source / --target aliases in the next section make the intent explicit:
create FROM TO --type X≡create --source TO --target FROM --type X
jira-link.py prints the resulting natural-language sentence on success, so you can verify the direction immediately:
Created: IOS-18 causes NRS-878 (link-type: Cause)Issue-to-issue links
# Create — see the direction rule above. TO is the active actor.
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py create PROJ-123 PROJ-456 --type Blocks
# → "PROJ-456 blocks PROJ-123"
# Equivalent named form (recommended for clarity):
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py create \
--source PROJ-456 --target PROJ-123 --type Blocks
# Preview without writing — also prints the resolved sentence
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py create PROJ-123 PROJ-456 --type Blocks --dry-run
# List — shows inward and outward links together
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py list PROJ-123
# Delete by link ID (from `list --json`)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py delete PROJ-123 --id 10042Link type naming: the canonical name (as displayed by Jira and stored in the link object) varies per instance — confirm yours via jira-link.py list-types or jira-fields.py search "link". The --type argument matches case-insensitively against the canonical name (blocks, Blocks, BLOCKS all resolve to the same type), so case mismatches will not fail; non-existent type names will.
Typical link types (names vary per instance)
In create FROM TO --type T, TO is the active party and uses the outward verb. The table is keyed on the link-type name as you pass it to --type.
--type value | Outward verb (what TO does to FROM) | Inward verb (how FROM is described) |
|---|---|---|
Blockade | blocks | is blocked by |
Cause | causes | is caused by |
Duplicate | duplicates | is duplicated by |
Relation | relates to | is related to |
Resolve | resolves | is resolved by |
Side effect | affects | is affected by |
Confirm the exact names on your instance via jira-link.py list-types or the admin panel.
Worked examples
Each example shows the call, the resulting natural-language sentence, and what each endpoint's view shows in the Jira UI after the link is created.
1. Blocker (infrastructure blocks a frontend ticket)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py create FRONTEND-12 INFRA-99 --type Blockade
# Created: INFRA-99 blocks FRONTEND-12 (link-type: Blockade)After creation:
- On FRONTEND-12 you see:
is blocked by ← INFRA-99 - On INFRA-99 you see:
blocks → FRONTEND-12
2. Root cause (root issue causes the observed effect)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py create EFFECT-1 ROOT-2 --type Cause
# Created: ROOT-2 causes EFFECT-1 (link-type: Cause)After creation:
- On EFFECT-1 you see:
is caused by ← ROOT-2 - On ROOT-2 you see:
causes → EFFECT-1
3. Side effect (a change affects an unrelated component)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py create AFFECTED-3 CHANGE-4 --type "Side effect"
# Created: CHANGE-4 affects AFFECTED-3 (link-type: Side effect)After creation:
- On AFFECTED-3 you see:
is affected by ← CHANGE-4 - On CHANGE-4 you see:
affects → AFFECTED-3
Bulk operations
bulk-create — create many links from a CSV
# CSV format (header required)
$ cat links.csv
from,to,type
IOS-18,NRS-878,Cause
IOS-18,NRT-4388,Deploy
IOS-18,NRS-3106,Side effect
# Preview every row's resolved sentence (no API calls)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py bulk-create \
--from-csv links.csv --dry-run
# Run for real, halting on first failure
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py bulk-create \
--from-csv links.csv
# Skip rows where a same-type link between FROM and TO already exists
# (Jira Server is NOT idempotent on link creation — duplicates are possible)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py bulk-create \
--from-csv links.csv --skip-existing
# Keep going past failures (records, doesn't abort)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py bulk-create \
--from-csv links.csv --continue-on-errorThe CSV from, to, type columns map to create FROM TO --type X exactly — same direction rule applies. Each row resolves through the same dry-run sentence before commit.
bulk-delete — delete many links by ID
# By comma-separated IDs
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py bulk-delete \
--ids 10042,10043,10044 --dry-run
# From a file (one ID per line)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py bulk-delete \
--ids-file ids.txtGet the IDs from jira-link list <ISSUE-KEY> --json first.
invert — fix a backwards link in one shot
# Preview: shows current sentence and inverted sentence
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py invert \
--id 10042 --dry-run
# Would invert: ROOT-2 causes EFFECT-1 → EFFECT-1 causes ROOT-2
# Commit the inversion (deletes original, creates swapped)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-link.py invert --id 10042Destructive — the original link is deleted before the inverted one is created. If the create fails, the script attempts to recreate the original. If both calls fail, you'll get an INCONSISTENT STATE error pointing at the link ID — fix it in the Jira UI.
This is the one-shot fix when --dry-run shows you a backwards sentence after a create.
Real-world reference
The netresearch-jira skill bundles a linking-conventions reference with concrete CHILD/PARENT examples for the Netresearch Jira instance. The direction semantics there agree with this document and serve as a sanity check before you call create.
Web links (links to external URLs)
# Create a web link
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-weblink.py add PROJ-123 \
--url "https://example.com/design-doc" --title "Design doc"
# List
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-weblink.py list PROJ-123
# Delete by ID
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-weblink.py delete PROJ-123 --id 42Web links are scoped per issue; the same URL on two issues is two independent web links.
Multi-Profile Configuration
When to load
Load this reference whenever the user mentions more than one Jira instance, asks about --profile, .jira-profile, or ~/.jira/profiles.json, or when auto-resolution by URL/issue-key prefix is unclear.
Manage connections to multiple Jira instances via ~/.jira/profiles.json.
Profile Resolution Priority
When a script runs, it resolves which profile to use in this order:
1. Explicit `--profile` flag — --profile myprofile selects the named profile directly 2. Full Jira URL — matches the URL's host against each profile's url field (normalized, port-insensitive) 3. Issue key prefix — matches the project prefix (e.g. WEB from WEB-1381) against each profile's projects list 4. `.jira-profile` file — reads the profile name from a .jira-profile file in the current working directory 5. Default profile — uses the default key from profiles.json
If none of the above match, the script raises an error listing available profiles.
--profile Flag
All scripts accept --profile (or -P) to select a profile explicitly:
uv run scripts/core/jira-issue.py --profile cloud get WEB-123
uv run scripts/core/jira-search.py --profile server query "project = OPS"~/.jira/profiles.json Format
{
"default": "cloud",
"profiles": {
"cloud": {
"url": "https://yourcompany.atlassian.net",
"auth": "cloud",
"username": "user@example.com",
"api_token": "your-cloud-api-token",
"projects": ["WEB", "MOBILE", "API"]
},
"server": {
"url": "https://jira.yourcompany.com",
"auth": "pat",
"token": "your-personal-access-token",
"projects": ["OPS", "INFRA", "SRVMO"]
}
}
}Fields
| Field | Required | Description |
|---|---|---|
url | Always | Jira instance URL |
auth | No | "cloud" or "pat" (default: "pat") |
token | If auth: "pat" | Personal access token (Server/DC) |
username | If auth: "cloud" | Atlassian account email |
api_token | If auth: "cloud" | Atlassian API token |
projects | No | List of project prefixes for auto-resolution from issue keys |
Top-Level Keys
| Key | Description |
|---|---|
default | Name of the default profile (used as fallback) |
profiles | Object mapping profile names to their configuration |
.jira-profile File
Place a .jira-profile file in a project directory to set the default profile for that project:
echo "server" > /path/to/my-project/.jira-profileWhen you run a script from that directory without --profile and without an issue key match, the profile named in .jira-profile is used.
Auto-Resolution from Issue Key
When you reference an issue like WEB-123, the script extracts the project prefix WEB and checks each profile's projects list. If exactly one profile lists WEB, that profile is selected automatically.
If multiple profiles claim the same project prefix, the script raises an error asking you to disambiguate with --profile.
Migration and Management
- `--migrate`: Use
jira-setup.py --migrateto convert an existing~/.env.jirafile into a profile in~/.jira/profiles.json. - `--all-profiles`: Use
jira-validate.py --all-profilesto validate all configured profiles at once.
Fallback Behavior
If ~/.jira/profiles.json does not exist, scripts fall back to the legacy ~/.env.jira file and environment variables. The --profile flag requires profiles.json to exist.
QA Gather
When to load
Load this reference when reviewing a ticket transitioned to QA / In Review / Ready for Review, or when the user asks for "QA review", "peer review", "review and resolve", or pulls a ticket from a team-review queue. Also when a peer-review style runbook (e.g. `peer-qa-review`) needs single-call context discovery for Stage 0 of its lifecycle.
The script gives you everything a reviewer typically chases across 4–5 separate calls — issue + description + comments + worklog + structured issue links + web/remote links + URLs scraped from prose (MR/PR/pipeline/commit/tag/release) + sibling tickets — in one shot.
Command
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-qa-gather.py PROJ-123
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-qa-gather.py PROJ-123 --jsonRead-only. No --dry-run needed.
Options
| Flag | Default | Effect |
|---|---|---|
--json | off | Emit a single JSON object with everything (machine-readable, full bundle). Default is human-readable summary. |
--quiet, -q | off | Print only the issue key after a successful fetch (validates connectivity/permissions/existence first). |
--no-siblings | off | Skip the sibling-ticket JQL search. |
--sibling-window DAYS | 60 | Sibling search looks at tickets updated >= -<DAYS>d. Min: 1. |
--max-siblings N | 5 | Cap on sibling tickets returned. Min: 1. |
--profile, --env-file, --debug | — | Standard global flags (see multi-profile.md for --profile). |
Output (default mode)
Human-readable sections, in order:
1. Issue key + summary 2. Status, comment count, worklog count + total minutes 3. Structured issue links (<type> → <key>: <summary> for outward, ← for inward) 4. Web/remote links (title: url) 5. URLs extracted from prose, grouped by category: merge_request, pull_request, pipeline, commit, tag, release, issue_link 6. Sibling tickets in the same project, sorted by updated DESC
JSON shape (with --json)
Top-level keys (stable):
issue_key— string, the requested keyissue— full Jira issue dict fromclient.issue()withexpand=renderedFieldscomments— list of comment dicts (extracted from the issue payload, no second API call)worklogs— list of worklog dictsworklog_total_seconds— intissue_links— list (rawissuelinksfrom the issue)web_links— list (fromget_issue_remote_links)extracted_urls—{category: [url, ...]}deduplicated, order-preservedsiblings— list of issue dicts (summary + status + resolutiondate + updated)
Sibling-search semantics
Same project, summary-token overlap (case-insensitive heuristic, 4-char minimum, stop-list filtered, max 5 keywords from the source ticket's summary), updated >= -<window>d, ordered by updated DESC. Includes both resolved and still-open tickets — open sibling work is often the most relevant for QA. Project and issue keys are quoted in the JQL string to handle keys with special characters.
Failure modes
- Issue fetch fails → script exits non-zero with a sanitized error.
- Worklog / web-links / sibling-search failures → warning to stderr, the corresponding JSON field is empty/
[], the script continues. The first (issue) fetch is the only hard dependency. - Exception messages are passed through
_sanitize_error()to redact tokens / passwords / api keys before being printed.
Companion runbook
The `peer-qa-review` skill provides the what to check / how to format the QA comment layer; this script provides the fetch the data layer. They compose: peer-qa-review's Stage 0 is "run jira-qa-gather; structure the rest of the review around the bundle."
If you have peer-qa-review loaded, prefer to follow its lifecycle (Claim → Discover → Formal → Functional+Inventory → Docs+Rollback+Comm → Verdict). If not, this script's output is still self-contained enough for a manual review pass.
Troubleshooting Guide
When to load
Load this reference whenever any script returns a non-zero exit code related to authentication, SSL, connectivity, or environment configuration — typically surfaced as HTTP 401/403, certificate errors, or JIRA_URL not set.
Setup Validation
Always start with:
uv run scripts/core/jira-validate.py --verboseExit Codes
| Code | Meaning | Action |
|---|---|---|
| 0 | All checks passed | Ready to use |
| 1 | Runtime dependency missing | Install uv |
| 2 | Environment config error | Check ~/.env.jira |
| 3 | Connectivity/auth failure | Verify credentials |
Configuration
Scripts load configuration in priority order: 1. Explicit --env-file parameter (if provided) 2. ~/.jira/profiles.json (if exists) — supports multiple Jira instances with auto-resolution from issue key, URL, or .jira-profile file (see references/multi-profile.md) 3. ~/.env.jira file (legacy single-instance config) 4. Environment variables (fallback for missing values)
You can use any of these approaches. For multiple Jira instances, use ~/.jira/profiles.json.
Option A: Environment File
Create ~/.env.jira:
Jira Cloud
JIRA_URL=https://yourcompany.atlassian.net
JIRA_USERNAME=your-email@example.com
JIRA_API_TOKEN=your-api-token-hereJira Server/Data Center
JIRA_URL=https://jira.yourcompany.com
JIRA_PERSONAL_TOKEN=your-personal-access-tokenOption B: Environment Variables
Export variables directly (useful in CI/CD or when credentials are managed externally):
# Jira Cloud
export JIRA_URL=https://yourcompany.atlassian.net
export JIRA_USERNAME=your-email@example.com
export JIRA_API_TOKEN=your-api-token-here
# Or Jira Server/DC
export JIRA_URL=https://jira.yourcompany.com
export JIRA_PERSONAL_TOKEN=your-personal-access-tokenCommon Errors
"Configuration errors: Missing required"
Cause: Required variables not found in file or environment.
Fix: 1. Check ~/.env.jira exists with correct values, OR 2. Verify environment variables are exported 3. Variable names are case-sensitive 4. No quotes around values needed in .env.jira
"Failed to connect to Jira"
Cause: Network, URL, or SSL issues.
Fix: 1. Verify URL is correct (include https://) 2. Test URL in browser 3. Check VPN if on corporate network 4. For self-signed certs, may need JIRA_VERIFY_SSL=false
"401 Unauthorized"
Cause: Invalid credentials.
Cloud Fix: 1. Generate new API token at https://id.atlassian.com/manage-profile/security/api-tokens 2. Use email as JIRA_USERNAME, not display name
Server/DC Fix: 1. Create PAT in Jira: Profile → Personal Access Tokens 2. Use only JIRA_PERSONAL_TOKEN, not username/password
"403 Forbidden"
Cause: Valid auth but no permission.
Fix: 1. Verify account has project access 2. Check if IP allowlisting blocks API access 3. Confirm API access not disabled by admin
"No such option: --json"
Cause: Flag placed after subcommand.
Fix: Move flags before subcommand:
# Wrong
uv run scripts/core/jira-issue.py get PROJ-123 --json
# Correct
uv run scripts/core/jira-issue.py --json get PROJ-123"Transition 'X' not available" (passing the transition ID)
Cause: jira-transition.py do expects the target status name, not the numeric transition ID that jira-transition.py list prints in its leftmost column.
Fix: Pass the destination status, in quotes:
# Wrong — 311 is the transition ID from `list`
uv run scripts/workflow/jira-transition.py do PROJ-123 311
# Correct — the To-Status name
uv run scripts/workflow/jira-transition.py do PROJ-123 "Resolved"When two transitions share a name but differ by icon (e.g. "✅ QA" → Resolved vs "❌ QA" → Reopened), disambiguate by passing the target status ("Resolved" / "Reopened"), which is unique.
"Issue does not exist"
Cause: Wrong key or no permission.
Fix: 1. Verify issue key spelling and case 2. Confirm you have "Browse" permission on project 3. Check if issue was moved/deleted
"Field 'xyz' cannot be set"
Cause: Field not editable or wrong format.
Fix: 1. Use jira-fields.py search xyz to find correct field ID 2. Check field is on the edit screen for that issue type 3. Verify field format (some need {"name": "value"})
Debug Mode
Add --debug for full stack traces:
uv run scripts/core/jira-issue.py --debug get PROJ-123Auth Mode Detection
Scripts auto-detect auth mode:
- If
JIRA_PERSONAL_TOKENset → Server/DC PAT auth - If
JIRA_USERNAME+JIRA_API_TOKENset → Cloud basic auth - URL containing
.atlassian.net→ Cloud mode
Override with JIRA_CLOUD=true or JIRA_CLOUD=false.
Versions — Releases and Fix/Affects Versions
When to load
Load this reference whenever the user asks about fix/affects versions, releases, or CRUD on project versions (list, get, create, update, release, unrelease, archive, unarchive, move, merge, delete).
List
# Default: unreleased versions in the project's native sequence (server order)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py list PROJ
# Filter by status (released | unreleased | archived | all)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py list PROJ --status released
# Paginated search with free-text query and explicit ordering
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py list PROJ \
--status unreleased --query "1.4" --order-by releaseDate
# Machine-readable outputs
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py --json list PROJ
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py --quiet list PROJGet
# By numeric ID
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py get 10042
# By name (requires --project to disambiguate)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py get "1.4.0" --project PROJ
# With fixed / affected / unresolved counts merged in
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py get 10042 --countsCreate
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py create PROJ "1.4.0" \
--release-date 2026-05-31 --description "Q2 2026 release"
# Full form with a start date and explicit released/archived flags
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py create PROJ "1.5.0" \
--start-date 2026-06-01 --release-date 2026-06-30 --released --archived
# Preview the composed payload without hitting the API
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py create PROJ "1.4.0" \
--release-date 2026-05-31 --dry-runUpdate
# Any subset of fields; internally GET → merge → PUT to protect omitted fields
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py update 10042 \
--description "Q2 2026 release (postponed)" --release-date 2026-06-07
# Renaming
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py update 10042 --name "1.4.0-rc2"
# Preview merged payload
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py update 10042 --name "1.4.0-rc2" --dry-runRelease / unrelease
# Mark released with a specific date
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py release 10042 --release-date 2026-05-31
# Omit --release-date to default to today
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py release 10042
# Roll back: sets released=false and explicitly clears releaseDate (null in payload)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py unrelease 10042Archive / unarchive
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py archive 10039
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py unarchive 10039Move
# After another version (IDs must be numeric; the script builds the
# `self` URL client-side from the configured Jira base URL before POSTing)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py move 10045 --after 10042
# Relative position: First | Last | Earlier | Later
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py move 10042 --position FirstMerge
# Preview: fetches relatedIssueCounts on the source and prints what would move
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py merge 10050 INTO 10042 --dry-run
# Execute: reassigns fixVersions/versions references, then deletes the source
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py merge 10050 INTO 10042Delete
# Safe: reassign fix-version refs to another version before deleting
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py delete 10050 --move-fix-to 10042
# Reassign both fix and affects references
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py delete 10050 \
--move-fix-to 10042 --move-affected-to 10042
# Preview (shows orphan counts when no --move-*-to is provided)
uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py delete 10050 --dry-runGotchas
- Plural field names only. On issues, use
fixVersions(Fix Version/s) andversions(Affects Version/s). The singular formsfixVersionandversionsilently no-op on create and give a confusing "field does not exist on screen" error on update. - Safe-merge update.
updatealways performs GET → merge → PUT because some Jira deployments treat PUT as replace. Clearing a field (e.g.unrelease) emits an explicitnullin the payload rather than omitting the key. - 409 on duplicate names. Creating a version whose name already exists in the project returns HTTP 409; the script surfaces it as
Version "X" already exists in PROJ. - Orphaned references on delete.
deletewithout--move-fix-to/--move-affected-toleaves danglingfixVersions/versionsarrays on issues. Prefer--dry-runfirst to read the reassign counts. - Numeric IDs only on mutating subcommands.
update,release,unrelease,archive,unarchive,move,merge,deletevalidate that every positional and target version ID is numeric before any HTTP call, so values like../../issue/KEYcannot reach the REST path. Look the version up by name (get NAME --project PROJ) first if you only have a name. - Paginated endpoint fallback.
--query/--order-byuse the paginated/project/{key}/versionendpoint (Jira Cloud + DC ≥9.x). On older DC the endpoint returns 404 and the script automatically retries the flat endpoint, applying--querysubstring filter and--order-bysort client-side. - Archived still filterable. Archive only hides a version from pickers; JQL like
fixVersion = "1.3.0"keeps matching archived versions.
See also
docs/plans/2026-04-20-versions-design.md for the full design trail.
Watchers
When to load
Load this reference whenever the user asks about watchers — listing, adding, removing, or auto-subscribing themselves or a stakeholder when an issue changes state. Watchers are not exposed anywhere else in the skill, so any "watch", "subscribe", "notify me on", "unsubscribe", or "who is watching" request should land here.
Commands
All commands are subcommands of jira-watchers.py. Global flags (--json, --quiet, --profile, --env-file, --debug) go before the subcommand.
list
# Default — header with count, one row per watcher
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py list PROJ-123
# JSON — raw Jira response ({"watchCount", "isWatching", "watchers": [...]})
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py --json list PROJ-123
# Quiet — one identifier per line
# DC prints usernames; Cloud prints accountIds (pipeline-friendly)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py --quiet list PROJ-123add
# Self-subscribe (default — requires only Browse Projects)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py add PROJ-123
# Subscribe someone else (requires Manage Watchers permission)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py add PROJ-123 --user product.owner
# Cloud: pass an accountId directly to skip user-search
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py add PROJ-123 --user 557058:d5765ebc-27de-4ce3-b520-a77a87e5e99a
# JSON output → {"key": "PROJ-123", "user": "asmith", "added": true}
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py --json add PROJ-123remove
# Un-watch yourself
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py remove PROJ-123
# Remove someone else (requires Manage Watchers)
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py remove PROJ-123 --user asmith
# Preview without calling the API
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py remove PROJ-123 --user asmith --dry-run
# JSON output → {"key": "PROJ-123", "user": "asmith", "removed": true}
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py --json remove PROJ-123Bulk patterns (no server-side bulk endpoint)
# Watch every child of an epic
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-search.py --json query '"Epic Link" = PROJ-789' \
| jq -r '.issues[].key' \
| xargs -I{} uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-watchers.py add {}Gotchas
- DC vs Cloud identity. DC uses usernames (
jdoe); Cloud uses accountIds
(557058:...). The script auto-detects via client.cloud and resolve_assignee() — pass whatever identifier you have; account-id-shaped strings bypass user search.
- `issue_delete_watcher` library kwargs differ from raw REST params.
In atlassian-python-api, call issue_delete_watcher(..., username=...) on DC and issue_delete_watcher(..., account_id=...) on Cloud; the script chooses the correct kwarg based on deployment/identifier shape. If you ever drop to raw REST, the query parameter names are ?username= (DC) and ?accountId= (Cloud).
- Self-watch is idempotent. Adding yourself when already watching returns
HTTP 204, not an error — the script treats repeated self-adds as success.
- 403 on someone-else add/remove. Non-self watcher changes require the
Manage Watchers project permission. A 403 is surfaced verbatim as the error message — do not silently swallow.
- 404 on remove-non-watcher. Removing a user who is not currently watching
returns HTTP 404 on both DC and Cloud. The script surfaces this as a clean error (exit code 1), not a silent success.
See also
docs/plans/2026-04-20-watchers-design.md for the full design trail (REST shapes, DC vs Cloud matrix, out-of-scope items).
Worklogs — Advanced Logging and Cross-Cutting Queries
When to load
Load this reference whenever the user wants to log work with a custom start date/time, or query worklogs across multiple issues by date range, user, project, epic or sprint.
jira-worklog.py add — advanced flags
# Simplest — logs "now" against your account
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-worklog.py add PROJ-123 2h --comment "Work done"
# Explicit start time
uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-worklog.py add PROJ-123 1h30m \
--started "2026-04-20T14:00:00" --comment "Research session"Time strings accept Nw Nd Nh Nm Ns combinations (Jira semantics, 8h workday).
jira-worklog-query.py — cross-cutting query
# Default: my worklogs for the current week
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-worklog-query.py
# By project with per-entry detail
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-worklog-query.py --project PROJ --detail
# By date range, JSON output
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-worklog-query.py \
--from 2026-03-01 --to 2026-03-31 --json
# By epic or sprint
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-worklog-query.py --epic PROJ-1940
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-worklog-query.py --sprint 916--detail shows individual worklog entries grouped by issue. Default output groups by issue with per-issue and grand totals. --json emits the raw worklog list — pipe to jq for custom reports.
Relative dates
--from and --to accept plain YYYY-MM-DD. For rolling queries, compute the dates in the shell:
uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-worklog-query.py \
--from "$(date -d 'monday last week' -I)" --to "$(date -I)"#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "atlassian-python-api>=3.41.0,<4",
# "click>=8.1.0,<9",
# "requests>=2.31.0,<3",
# ]
# ///
"""Jira attachment operations - download and upload attachments."""
import json
import mimetypes
import sys
from pathlib import Path
from urllib.parse import urlparse
# ═══════════════════════════════════════════════════════════════════════════════
# Shared library import (TR1.1.1 - PYTHONPATH approach)
# ═══════════════════════════════════════════════════════════════════════════════
_script_dir = Path(__file__).parent
_lib_path = _script_dir.parent / "lib"
if _lib_path.exists():
sys.path.insert(0, str(_lib_path.parent))
import click
import requests
from lib.client import (
AuthenticationError,
CaptchaError,
LazyJiraClient,
SessionExpiredError,
_handle_response,
_sanitize_error,
)
from lib.config import load_config, normalize_netloc
from lib.output import error, success, warning
# Chunk size for streaming large file downloads (1 MB)
CHUNK_SIZE = 1048576
# Timeout for attachment downloads (connect_timeout, read_timeout)
DOWNLOAD_TIMEOUT = (10, 300)
# Uploads can be large — keep connect timeout low but allow long reads.
UPLOAD_TIMEOUT = (10, 300)
# ═══════════════════════════════════════════════════════════════════════════════
# Security Helpers
# ═══════════════════════════════════════════════════════════════════════════════
def validate_attachment_url(attachment_url: str, jira_url: str) -> bool:
"""Validate that an attachment URL points to the configured Jira host.
Prevents SSRF attacks where a malicious URL could exfiltrate Jira
credentials to an attacker-controlled server.
Args:
attachment_url: The attachment URL to validate
jira_url: The configured JIRA_URL to validate against
Returns:
True if the URL is safe to request with credentials
"""
# Relative paths are always safe — they get prefixed with JIRA_URL
if not attachment_url.startswith(("http://", "https://")):
return True
return normalize_netloc(attachment_url) == normalize_netloc(jira_url)
def validate_output_path(output_file: str, working_dir: str) -> Path | None:
"""Validate output path against path traversal attacks.
Ensures the resolved output path stays within the working directory.
Args:
output_file: The requested output file path
working_dir: The working directory to constrain output to
Returns:
Resolved Path if valid, None if path traversal detected
"""
work = Path(working_dir).resolve()
output_path = (work / output_file).resolve() if not Path(output_file).is_absolute() else Path(output_file).resolve()
try:
output_path.relative_to(work)
except ValueError:
return None
return output_path
# ═══════════════════════════════════════════════════════════════════════════════
# Download Helpers (shared by `download` and `download-all`)
# ═══════════════════════════════════════════════════════════════════════════════
class DownloadError(Exception):
"""Raised for download-level anomalies (CDN redirects, TLS downgrade)."""
def _build_auth(config: dict) -> tuple[tuple[str, str] | None, dict]:
"""Build (auth, headers) for an authenticated Jira request.
Personal access tokens go in a Bearer header; Cloud uses basic auth.
"""
if "JIRA_PERSONAL_TOKEN" in config:
return None, {"Authorization": f"Bearer {config['JIRA_PERSONAL_TOKEN']}"}
return (config["JIRA_USERNAME"], config["JIRA_API_TOKEN"]), {}
def _stream_to_path(url: str, jira_url: str, auth, headers: dict, safe_path: Path) -> None:
"""Stream an attachment URL to safe_path with CDN-redirect protection.
Follows exactly one CDN redirect without forwarding credentials, refuses
TLS downgrades, and rejects unexpected redirect chains so a 302 HTML body
is never written as the file. Raises DownloadError on redirect anomalies;
propagates the typed auth errors from _handle_response().
"""
response = requests.get(
url,
auth=auth,
headers=headers,
allow_redirects=False,
stream=True,
verify=True,
timeout=DOWNLOAD_TIMEOUT,
)
# Follow one CDN redirect without forwarding credentials (Jira Cloud stores
# attachments in S3/CDN which returns 302).
if response.status_code in (301, 302, 303, 307, 308) and "Location" in response.headers:
redirect_url = response.headers["Location"]
# Reject HTTP downgrade — prevents MITM on non-TLS redirects
if redirect_url.startswith("http://"):
raise DownloadError("refusing HTTP redirect (TLS downgrade)")
response = requests.get(
redirect_url,
allow_redirects=False,
stream=True,
verify=True,
timeout=DOWNLOAD_TIMEOUT,
)
# Reject unexpected redirect (e.g., CDN chain with >1 hop) — without this
# the 302 HTML body would be silently saved as the file.
if 300 <= response.status_code < 400:
raise DownloadError(f"unexpected redirect (status {response.status_code})")
# _handle_response() raises typed errors for 401/403/session-expiry;
# raise_for_status() handles the remaining 4xx/5xx.
_handle_response(response, jira_url, url=getattr(response, "url", url))
response.raise_for_status()
with open(safe_path, "wb") as f:
for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
f.write(chunk)
def _report_download_error(ctx, exc: Exception) -> None:
"""Map a download exception to a user-facing message and exit non-zero."""
if ctx.obj.get("debug"):
raise exc
if isinstance(exc, CaptchaError):
raise exc
if isinstance(exc, KeyError):
# Config key names are non-sensitive metadata — no sanitization needed.
error(f"Missing required configuration: {exc}")
elif isinstance(exc, (SessionExpiredError, AuthenticationError)):
error(_sanitize_error(str(exc)))
elif isinstance(exc, (DownloadError, requests.exceptions.RequestException)):
error(f"Download failed: {_sanitize_error(str(exc))}")
else:
error(f"Failed to download attachment: {_sanitize_error(str(exc))}")
sys.exit(1)
# ═══════════════════════════════════════════════════════════════════════════════
# CLI Definition
# ═══════════════════════════════════════════════════════════════════════════════
@click.group()
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
@click.option("--quiet", "-q", is_flag=True, help="Minimal output")
@click.option("--env-file", type=click.Path(), help="Environment file path")
@click.option("--profile", "-P", help="Jira profile name from ~/.jira/profiles.json")
@click.option("--debug", is_flag=True, help="Show debug information on errors")
@click.pass_context
def cli(ctx, output_json: bool, quiet: bool, env_file: str | None, profile: str | None, debug: bool):
"""Jira attachment operations.
Download and upload Jira issue attachments.
"""
ctx.ensure_object(dict)
ctx.obj["json"] = output_json
ctx.obj["quiet"] = quiet
ctx.obj["env_file"] = env_file
ctx.obj["profile"] = profile
ctx.obj["debug"] = debug
ctx.obj["client"] = LazyJiraClient(env_file=env_file, profile=profile)
@cli.command()
@click.argument("attachment_url")
@click.argument("output_file")
@click.pass_context
def download(ctx, attachment_url: str, output_file: str):
"""Download a Jira attachment.
ATTACHMENT_URL: Full URL or attachment ID/content path
OUTPUT_FILE: Output file path
Examples:
jira-attachment download https://example.atlassian.net/rest/api/2/attachment/content/12345 file.zip
jira-attachment download /rest/api/2/attachment/content/12345 file.zip
"""
try:
# Load config for authentication (pass URL for host-based profile resolution)
if attachment_url.startswith(("http://", "https://")):
config = load_config(env_file=ctx.obj["env_file"], profile=ctx.obj.get("profile"), url=attachment_url)
else:
config = load_config(env_file=ctx.obj["env_file"], profile=ctx.obj.get("profile"))
jira_url = config["JIRA_URL"]
# SSRF protection: validate attachment URL host matches JIRA_URL
if not validate_attachment_url(attachment_url, jira_url):
att_host = urlparse(attachment_url).netloc
jira_host = urlparse(jira_url).netloc
error(f"Attachment URL host '{att_host}' does not match JIRA_URL host '{jira_host}'")
sys.exit(1)
# Determine authentication method
auth, headers = _build_auth(config)
# Build full URL if needed
if attachment_url.startswith(("http://", "https://")):
url = attachment_url
else:
url = jira_url + attachment_url
# Path traversal protection: validate output path
safe_path = validate_output_path(output_file, Path.cwd())
if safe_path is None:
error(f"Output path escapes working directory: {output_file}")
sys.exit(1)
parent_dir = safe_path.parent
if not parent_dir.exists():
error(f"Directory does not exist: {parent_dir}")
sys.exit(1)
if safe_path.exists() and not safe_path.is_file():
error(f"Output path exists and is not a file: {output_file}")
sys.exit(1)
_stream_to_path(url, jira_url, auth, headers, safe_path)
if ctx.obj["quiet"]:
print(str(safe_path))
elif ctx.obj["json"]:
print(json.dumps({"status": "success", "file": str(safe_path)}))
else:
success(f"Downloaded to: {safe_path}")
except Exception as e:
_report_download_error(ctx, e)
@cli.command("download-all")
@click.argument("issue_key")
@click.option("--dir", "output_dir", default=".", help="Output directory (created if missing; must stay within cwd)")
@click.option("--dry-run", is_flag=True, help="List attachments without downloading")
@click.pass_context
def download_all(ctx, issue_key: str, output_dir: str, dry_run: bool):
"""Download all attachments of a Jira issue.
ISSUE_KEY: The Jira issue key (e.g., PROJ-123)
Files are saved under --dir using their original Jira filenames. Duplicate
filenames are disambiguated with the attachment id. Files whose name would
escape --dir are skipped.
Examples:
jira-attachment download-all PROJ-123
jira-attachment download-all PROJ-123 --dir ./attachments
jira-attachment download-all PROJ-123 --dry-run
"""
try:
config = load_config(env_file=ctx.obj["env_file"], profile=ctx.obj.get("profile"))
jira_url = config["JIRA_URL"]
auth, headers = _build_auth(config)
# Path traversal protection: constrain output dir within cwd (matches `download`)
safe_dir = validate_output_path(output_dir, Path.cwd())
if safe_dir is None:
error(f"Output directory escapes working directory: {output_dir}")
sys.exit(1)
# Fetch attachment metadata for the issue
meta_response = requests.get(
f"{jira_url}/rest/api/2/issue/{issue_key}",
params={"fields": "attachment"},
auth=auth,
headers={**headers, "Accept": "application/json"},
verify=True,
timeout=DOWNLOAD_TIMEOUT,
)
_handle_response(meta_response, jira_url, url=getattr(meta_response, "url", None))
meta_response.raise_for_status()
attachments = (meta_response.json().get("fields") or {}).get("attachment") or []
if not attachments:
if ctx.obj["json"]:
print(json.dumps({"status": "success", "issue": issue_key, "count": 0, "downloaded": []}))
elif not ctx.obj["quiet"]:
warning(f"No attachments on {issue_key}")
return
if dry_run:
if ctx.obj["json"]:
print(
json.dumps(
{
"status": "dry-run",
"issue": issue_key,
"count": len(attachments),
"attachments": [
{"id": att.get("id"), "filename": att.get("filename"), "size": att.get("size", 0)}
for att in attachments
],
}
)
)
elif ctx.obj["quiet"]:
for att in attachments:
print(att.get("filename"))
else:
warning(f"DRY RUN — {len(attachments)} attachment(s) on {issue_key}:")
for att in attachments:
print(f" {att.get('filename')} ({att.get('size', 0):,} bytes)")
return
safe_dir.mkdir(parents=True, exist_ok=True)
downloaded: list[str] = []
seen: set[str] = set()
for att in attachments:
# Strip any path components from the Jira-supplied filename (untrusted)
filename = Path(att.get("filename", "")).name
if not filename:
warning(f"Skipping attachment with empty filename (id={att.get('id')})")
continue
# Disambiguate duplicate filenames so they don't overwrite each other
if filename in seen:
filename = f"{att.get('id', 'dup')}_{filename}"
seen.add(filename)
dest = validate_output_path(filename, str(safe_dir))
if dest is None:
warning(f"Skipping unsafe filename: {att.get('filename')!r}")
continue
# Per-file resilience: a single bad file (404/500/redirect anomaly)
# must not abort the whole batch. Auth/session/CAPTCHA errors are NOT
# caught here — they propagate and abort, since retrying is pointless.
try:
_stream_to_path(att["content"], jira_url, auth, headers, dest)
except (DownloadError, requests.exceptions.RequestException) as e:
warning(f"Skipping {filename}: {_sanitize_error(str(e))}")
continue
downloaded.append(str(dest))
if ctx.obj["quiet"]:
for path in downloaded:
print(path)
elif ctx.obj["json"]:
print(
json.dumps(
{"status": "success", "issue": issue_key, "count": len(downloaded), "downloaded": downloaded}
)
)
else:
success(f"Downloaded {len(downloaded)}/{len(attachments)} attachment(s) from {issue_key} to {safe_dir}")
except Exception as e:
_report_download_error(ctx, e)
@cli.command("add")
@click.argument("issue_key")
@click.argument("file_path", type=click.Path(exists=True, dir_okay=False, readable=True))
@click.option("--dry-run", is_flag=True, help="Validate file without uploading")
@click.pass_context
def add(ctx, issue_key: str, file_path: str, dry_run: bool):
"""Upload an attachment to a Jira issue.
ISSUE_KEY: The Jira issue key (e.g., PROJ-123)
FILE_PATH: Path to the file to attach
Examples:
jira-attachment add PROJ-123 screenshot.png
jira-attachment add PROJ-123 /tmp/report.pdf --dry-run
"""
client = ctx.obj["client"]
client.with_context(issue_key=issue_key)
path = Path(file_path)
file_size = path.stat().st_size
if dry_run:
warning("DRY RUN — would upload:")
print(f" File: {path.name} ({file_size:,} bytes)")
print(f" Issue: {issue_key}")
return
try:
mime_type, _ = mimetypes.guess_type(path.name)
mime_type = mime_type or "application/octet-stream"
url = f"{client.url}/rest/api/2/issue/{issue_key}/attachments"
headers = {"X-Atlassian-Token": "nocheck"}
with path.open("rb") as f:
files = {"file": (path.name, f, mime_type)}
response = client._session.post(url, files=files, headers=headers, timeout=UPLOAD_TIMEOUT)
response.raise_for_status()
result = response.json()
if ctx.obj["quiet"]:
if isinstance(result, list) and result and isinstance(result[0], dict):
print(result[0].get("id", ""))
else:
print("")
elif ctx.obj["json"]:
print(json.dumps(result if isinstance(result, list) else [result], indent=2))
else:
success(f"Attached {path.name} ({file_size:,} bytes) to {issue_key}")
except CaptchaError:
raise
except requests.HTTPError as e:
if ctx.obj["debug"]:
raise
error(f"Failed to upload attachment: {_sanitize_error(str(e))}")
sys.exit(1)
except Exception as e:
if ctx.obj["debug"]:
raise
error(f"Failed to upload attachment: {_sanitize_error(str(e))}")
sys.exit(1)
if __name__ == "__main__":
cli()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "atlassian-python-api>=3.41.0,<4",
# "click>=8.1.0,<9",
# ]
# ///
"""Jira search operations - query issues using JQL."""
import re
import sys
from pathlib import Path
# ═══════════════════════════════════════════════════════════════════════════════
# Shared library import (TR1.1.1 - PYTHONPATH approach)
# ═══════════════════════════════════════════════════════════════════════════════
_script_dir = Path(__file__).parent
_lib_path = _script_dir.parent / "lib"
if _lib_path.exists():
sys.path.insert(0, str(_lib_path.parent))
import click
from lib.client import LazyJiraClient
from lib.output import error, format_output, format_table, warning
# ═══════════════════════════════════════════════════════════════════════════════
# CLI Definition
# ═══════════════════════════════════════════════════════════════════════════════
@click.group()
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
@click.option("--quiet", "-q", is_flag=True, help="Minimal output (keys only)")
@click.option("--env-file", type=click.Path(), help="Environment file path")
@click.option("--profile", "-P", help="Jira profile name from ~/.jira/profiles.json")
@click.option("--debug", is_flag=True, help="Show debug information on errors")
@click.pass_context
def cli(ctx, output_json: bool, quiet: bool, env_file: str | None, profile: str | None, debug: bool):
"""Jira search operations.
Query Jira issues using JQL (Jira Query Language).
"""
ctx.ensure_object(dict)
ctx.obj["json"] = output_json
ctx.obj["quiet"] = quiet
ctx.obj["debug"] = debug
ctx.obj["client"] = LazyJiraClient(env_file=env_file, profile=profile)
_ORDER_BY_RE = re.compile(r"\border\s+by\b", re.IGNORECASE)
# Strip 'single-quoted' and "double-quoted" string literals so values
# like `summary ~ 'order by'` don't trip the ORDER BY detector.
_QUOTED_RE = re.compile(r"'(?:\\.|[^'\\])*'|\"(?:\\.|[^\"\\])*\"")
def _has_top_level_order_by(jql: str) -> bool:
"""True if JQL contains a real ORDER BY clause (ignores quoted literals)."""
return bool(_ORDER_BY_RE.search(_QUOTED_RE.sub("", jql)))
def _append_order_by(jql: str, order_by_clauses: tuple[str, ...]) -> str:
"""Append --order-by clauses to a JQL string.
Errors if the JQL already contains an ORDER BY (case-insensitive,
ignoring quoted string literals so `summary ~ 'order by'` does not
trigger). The user has to choose one form because concatenation would
produce invalid JQL.
"""
if not order_by_clauses:
return jql
if _has_top_level_order_by(jql):
raise click.UsageError(
"JQL already contains 'ORDER BY'; pass either --order-by or embed "
"ORDER BY in the JQL, not both. "
"Tip: ORDER BY can also be embedded directly in the JQL string."
)
cleaned: list[str] = []
for clause in order_by_clauses:
clause = (clause or "").strip()
if not clause:
raise click.UsageError(
'--order-by requires a non-empty value, e.g. --order-by "updated DESC". '
"Tip: ORDER BY can also be embedded directly in the JQL string."
)
cleaned.append(clause)
return f"{jql.rstrip()} ORDER BY {', '.join(cleaned)}"
@cli.command()
@click.argument("jql")
@click.option("--max-results", "-n", default=50, help="Maximum results to return")
@click.option("--fields", "-f", default="key,summary,status,assignee,priority", help="Comma-separated fields to return")
@click.option(
"--start-at",
default=0,
type=click.IntRange(min=0),
help="Starting index for pagination (0-based)",
)
@click.option("--truncate", type=int, metavar="N", help="Truncate field values to N characters")
@click.option(
"--order-by",
"order_by",
multiple=True,
metavar="FIELD [ASC|DESC]",
help=(
'Append an ORDER BY clause to the JQL (e.g. "updated DESC"). '
"Repeatable for multi-key sorts. Errors if the JQL already contains ORDER BY. "
"Tip: ORDER BY can also be embedded directly in the JQL string."
),
)
@click.pass_context
def query(
ctx,
jql: str,
max_results: int,
fields: str,
start_at: int,
truncate: int | None,
order_by: tuple[str, ...],
):
"""Search issues using JQL.
JQL: Jira Query Language query string (passed directly to Jira API — treat as trusted input)
Examples:
jira-search query "project = PROJ AND status = 'In Progress'"
jira-search query "assignee = currentUser()" --max-results 20
jira-search query "project = PROJ" --order-by "updated DESC"
jira-search query "project = PROJ" --order-by "priority DESC" --order-by "created ASC"
jira-search --json query "updated >= -7d"
jira-search --quiet query "labels = urgent"
Common JQL patterns:
project = PROJ # Issues in project
assignee = currentUser() # My issues
status = "In Progress" # By status
updated >= -7d # Updated last 7 days
sprint in openSprints() # Current sprint
labels = backend # By label
priority = High # By priority
Sorting:
ORDER BY can be embedded directly in the JQL string
(e.g. "project = PROJ ORDER BY updated DESC") or supplied via the
--order-by flag. Use one form or the other, not both.
"""
client = ctx.obj["client"]
try:
jql = _append_order_by(jql, order_by)
except click.UsageError as e:
error(str(e))
sys.exit(2)
field_list = [f.strip() for f in fields.split(",")]
try:
results = client.jql(jql, limit=max_results, start=start_at, fields=field_list)
except Exception as e:
if ctx.obj["debug"]:
raise
error(f"Search failed: {e}")
sys.exit(1)
issues = results.get("issues", [])
total = results.get("total")
_warn_if_capped(issues, total, max_results, start_at)
_emit_query_output(ctx, issues, field_list, truncate, total, start_at)
def _warn_if_capped(issues: list, total, max_results: int, start_at: int) -> None:
if isinstance(total, int) and max_results > len(issues) and (start_at + len(issues)) < total:
warning(
f"Server capped results: requested --max-results {max_results}, "
f"received {len(issues)} (total matches: {total}). "
"Use pagination with --start-at to fetch further pages."
)
def _emit_query_output(ctx, issues: list, field_list: list, truncate: int | None, total, start_at: int) -> None:
"""Render search results in json / quiet / table form."""
if ctx.obj["json"]:
format_output(issues, as_json=True)
return
if ctx.obj["quiet"]:
for issue in issues:
print(issue["key"])
return
if total is None:
total = len(issues)
if not issues:
if total > 0:
print(f"No issues on this page (total: {total}). Try a smaller --start-at.")
else:
print("No issues found")
return
_print_results_table(issues, field_list, truncate=truncate)
issue_label = "issue" if total == 1 else "issues"
print(f"\n(showing {start_at + 1}-{start_at + len(issues)} of {total} {issue_label})")
def _print_results_table(issues: list, fields: list, truncate: int | None = None) -> None:
"""Print search results as a table.
Args:
issues: List of issue dicts from Jira API
fields: List of field names to display
truncate: If set, truncate field values to this many characters
"""
# Build table data
rows = []
for issue in issues:
row = {"key": issue["key"]}
issue_fields = issue.get("fields", {})
for field in fields:
if field == "key":
continue
value = issue_fields.get(field)
# Handle nested objects
if isinstance(value, dict):
if "name" in value:
value = value["name"]
elif "displayName" in value:
value = value["displayName"]
elif "value" in value:
value = value["value"]
else:
value = str(value)
elif isinstance(value, list):
value = ", ".join(str(v) for v in value[:3])
if len(issue_fields.get(field, [])) > 3:
value += "..."
elif value is None:
value = "-"
else:
value = str(value)
# Truncate if requested
if truncate and len(str(value)) > truncate:
value = str(value)[: truncate - 3] + "..."
row[field] = value
rows.append(row)
# Print table
columns = ["key"] + [f for f in fields if f != "key"]
print(format_table(rows, columns))
if __name__ == "__main__":
cli()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "atlassian-python-api>=3.41.0,<4",
# "click>=8.1.0,<9",
# "requests>=2.31.0,<3",
# ]
# ///
"""Jira environment validation - verify runtime, configuration, and connectivity."""
import shutil
import subprocess
import sys
from pathlib import Path
# ═══════════════════════════════════════════════════════════════════════════════
# Shared library import (TR1.1.1 - PYTHONPATH approach)
# ═══════════════════════════════════════════════════════════════════════════════
_script_dir = Path(__file__).parent
_lib_path = _script_dir.parent / "lib"
if _lib_path.exists():
sys.path.insert(0, str(_lib_path.parent))
import json as json_module
import click
import requests
from lib.client import AuthenticationError, LazyJiraClient, SessionExpiredError, _sanitize_error
from lib.config import (
DEFAULT_ENV_FILE,
PROFILES_FILE,
get_auth_mode,
is_cloud_url,
load_config,
load_profiles,
profile_to_config,
validate_config,
)
from lib.output import error, format_table, success, warning
# ═══════════════════════════════════════════════════════════════════════════════
# Exit Codes (TR2.3)
# ═══════════════════════════════════════════════════════════════════════════════
EXIT_SUCCESS = 0
EXIT_RUNTIME_ERROR = 1
EXIT_CONFIG_ERROR = 2
EXIT_CONNECTION_ERROR = 3
def check_runtime(verbose: bool = False) -> tuple[bool, dict]:
"""Check runtime dependencies (D7)."""
checks_passed = True
info = {}
# Check uv/uvx
uv_path = shutil.which("uv")
if uv_path:
result = subprocess.run(["uv", "--version"], capture_output=True, text=True) # nosec B603 B607
uv_version = result.stdout.strip() if result.returncode == 0 else "unknown"
info["uv_path"] = uv_path
info["uv_version"] = uv_version
if verbose:
success(f"uv found: {uv_path} ({uv_version})")
else:
error(
"Runtime check failed: 'uv' command not found",
"To install uv, run:\n"
" pip install uv\n\n"
" Or visit: https://docs.astral.sh/uv/getting-started/installation/",
)
checks_passed = False
# Check Python version
py_version = sys.version_info
info["python_version"] = f"{py_version.major}.{py_version.minor}.{py_version.micro}"
if py_version >= (3, 10):
if verbose:
success(f"Python version: {py_version.major}.{py_version.minor}.{py_version.micro}")
else:
error(
f"Python version {py_version.major}.{py_version.minor} < 3.10 required",
"Please upgrade Python to 3.10 or later",
)
checks_passed = False
return checks_passed, info
def check_environment(env_file: str | None, profile: str | None = None, verbose: bool = False) -> dict | None:
"""Check environment configuration."""
try:
config = load_config(env_file=env_file, profile=profile)
errors = validate_config(config)
if errors:
for err in errors:
error(f"Configuration error: {err}")
return None
if env_file and profile:
warning("--profile is ignored because --env-file was provided")
if verbose:
if env_file:
path = Path(env_file)
success(f"Environment file: {path}")
elif profile:
success(f"Profile: {profile} (from {PROFILES_FILE})")
else:
# Detect whether profiles.json or legacy env file was used
try:
profiles_data = load_profiles()
default_name = profiles_data.get("default", "unknown")
success(f"Profile: {default_name} (default from {PROFILES_FILE})")
except (FileNotFoundError, ValueError):
success(f"Environment file: {DEFAULT_ENV_FILE}")
success(f"JIRA_URL: {config['JIRA_URL']}")
# Show auth mode-specific credentials
auth_mode = get_auth_mode(config)
if auth_mode == "pat":
success("Auth mode: Personal Access Token (Server/DC)")
success("JIRA_PERSONAL_TOKEN: ******* (hidden)")
else:
success("Auth mode: Username + API Token (Cloud)")
success(f"JIRA_USERNAME: {config.get('JIRA_USERNAME', 'N/A')}")
success("JIRA_API_TOKEN: ******* (hidden)")
if "JIRA_CLOUD" in config:
success(f"JIRA_CLOUD: {config['JIRA_CLOUD']}")
return config
except (FileNotFoundError, ValueError) as e:
error(str(e))
return None
def check_connectivity(
config: dict, project: str | None, profile: str | None = None, env_file: str | None = None, verbose: bool = False
) -> tuple[bool, dict]:
"""Check connectivity and authentication."""
url = config["JIRA_URL"]
info = {"url": url}
# Test server reachability
try:
response = requests.head(url, timeout=10, allow_redirects=True)
info["server_reachable"] = True
if verbose:
success(f"Server reachable: {url} (status: {response.status_code})")
except requests.exceptions.Timeout:
error(
f"Connection timeout: {url}",
"The server did not respond within 10 seconds.\n Check your network connection and JIRA_URL.",
)
return False, info
except requests.exceptions.ConnectionError as e:
error(f"Connection failed: {url}", f"Could not connect to the server.\n Error: {_sanitize_error(str(e))}")
return False, info
# Test authentication
try:
client = LazyJiraClient(env_file=env_file, profile=profile)
user = client.myself()
display_name = user.get("displayName", user.get("name", "Unknown"))
email = user.get("emailAddress", "N/A")
info["user"] = display_name
info["email"] = email
if verbose:
success(f"Authenticated as: {display_name} ({email})")
except SessionExpiredError as e:
error("Authentication failed", str(e))
return False, info
except AuthenticationError as e:
error("Authentication failed", str(e))
return False, info
except Exception as e:
error(
"Authentication failed",
f"Could not authenticate with the provided credentials.\n Error: {_sanitize_error(str(e))}",
)
return False, info
# Test project access (optional)
if project:
try:
proj = client.project(project)
info["project_access"] = project
if verbose:
success(f"Project access: {project} ({proj.get('name', 'Unknown')})")
else:
success(f"Project access verified: {project}")
except Exception as e:
warning(f"Could not access project {project}: {e}")
return True, info
def validate_all_profiles(output_json: bool = False, verbose: bool = False) -> int:
"""Validate all profiles in ~/.jira/profiles.json.
Returns:
Exit code (0 = all passed, 2 = config error, 3 = connectivity error)
"""
try:
data = load_profiles()
except (FileNotFoundError, ValueError) as e:
error(str(e))
return EXIT_CONFIG_ERROR
profiles = data["profiles"]
default_name = data.get("default", "")
results = []
for name, prof in profiles.items():
row = {
"Profile": name,
"URL": prof.get("url", "N/A"),
"Auth": prof.get("auth", "N/A"),
"Projects": ", ".join(prof.get("projects", [])) if isinstance(prof.get("projects"), list) else "-",
"Default": "Yes" if name == default_name else "",
}
try:
config = profile_to_config(prof)
except ValueError:
row["Status"] = "CONFIG ERROR"
results.append(row)
continue
errors = validate_config(config)
if errors:
row["Status"] = "CONFIG ERROR"
results.append(row)
continue
# Quick connectivity check
try:
response = requests.head(config["JIRA_URL"], timeout=5, allow_redirects=True)
if response.status_code < 400 or response.status_code in (401, 403):
row["Status"] = "OK"
else:
row["Status"] = f"HTTP {response.status_code}"
except Exception:
row["Status"] = "UNREACHABLE"
results.append(row)
if output_json:
print(json_module.dumps(results, indent=2))
else:
click.echo(f"Profiles from {PROFILES_FILE}:\n")
print(format_table(results, ["Profile", "URL", "Auth", "Projects", "Status", "Default"]))
click.echo()
ok_count = sum(1 for r in results if r["Status"] == "OK")
click.echo(f"{ok_count}/{len(results)} profiles reachable")
# Differentiate exit codes: config errors (2) vs connectivity errors (3)
has_config_errors = any(r["Status"] == "CONFIG ERROR" for r in results)
has_connectivity_errors = any(r["Status"] not in ("OK", "CONFIG ERROR") for r in results)
if has_connectivity_errors:
return EXIT_CONNECTION_ERROR
if has_config_errors:
return EXIT_CONFIG_ERROR
return EXIT_SUCCESS
@click.command()
@click.option("--json", "output_json", is_flag=True, help="Output as JSON")
@click.option("--quiet", "-q", is_flag=True, help="Minimal output")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
@click.option("--project", "-p", help="Verify access to specific project")
@click.option("--env-file", type=click.Path(exists=False), help="Path to environment file")
@click.option("--profile", "-P", help="Validate a specific profile from ~/.jira/profiles.json")
@click.option("--all-profiles", is_flag=True, help="Validate all profiles in ~/.jira/profiles.json")
@click.option("--debug", is_flag=True, help="Show debug information on errors")
def main(
output_json: bool,
quiet: bool,
verbose: bool,
project: str | None,
env_file: str | None,
profile: str | None,
all_profiles: bool,
debug: bool,
):
"""Validate Jira environment configuration.
Checks runtime dependencies, environment configuration, and connectivity
to ensure the Jira CLI scripts will work correctly.
\b
Exit codes:
0 - All checks passed
1 - Runtime dependency missing
2 - Environment configuration error
3 - Connectivity/authentication failure
\b
Examples:
# Validate default configuration
uv run scripts/core/jira-validate.py --verbose
# Validate a specific profile
uv run scripts/core/jira-validate.py --profile mkk --verbose
# Validate all profiles
uv run scripts/core/jira-validate.py --all-profiles
"""
# Handle --all-profiles mode
if all_profiles:
exit_code = validate_all_profiles(output_json=output_json, verbose=verbose)
sys.exit(exit_code)
result = {"status": "ok"}
# Suppress verbose output if JSON or quiet mode
show_verbose = verbose and not output_json and not quiet
if show_verbose:
click.echo("=" * 60)
if profile:
click.echo(f"Jira Environment Validation (profile: {profile})")
else:
click.echo("Jira Environment Validation")
click.echo("=" * 60)
click.echo()
# Check 1: Runtime
if show_verbose:
click.echo("Runtime Checks:")
runtime_ok, runtime_info = check_runtime(show_verbose)
result["runtime"] = runtime_info
if not runtime_ok:
result["status"] = "error"
result["error"] = "runtime_check_failed"
if output_json:
print(json_module.dumps(result, indent=2))
elif quiet:
print("error")
sys.exit(EXIT_RUNTIME_ERROR)
if show_verbose:
click.echo()
# Check 2: Environment
if show_verbose:
click.echo("Environment Checks:")
config = check_environment(env_file, profile, show_verbose)
if config is None:
result["status"] = "error"
result["error"] = "config_error"
if output_json:
print(json_module.dumps(result, indent=2))
elif quiet:
print("error")
sys.exit(EXIT_CONFIG_ERROR)
if profile:
result["profile"] = profile
result["url"] = config["JIRA_URL"]
result["server_type"] = "cloud" if is_cloud_url(config["JIRA_URL"]) else "server"
auth_mode = get_auth_mode(config)
result["auth_mode"] = auth_mode
if auth_mode == "cloud":
result["username"] = config.get("JIRA_USERNAME", "N/A")
if show_verbose:
click.echo()
# Check 3: Connectivity
if show_verbose:
click.echo("Connectivity Checks:")
conn_ok, conn_info = check_connectivity(config, project, profile=profile, env_file=env_file, verbose=show_verbose)
result["user"] = conn_info.get("user", "Unknown")
if "project_access" in conn_info:
result["project_access"] = conn_info["project_access"]
if not conn_ok:
result["status"] = "error"
result["error"] = "connectivity_error"
if output_json:
print(json_module.dumps(result, indent=2))
elif quiet:
print("error")
sys.exit(EXIT_CONNECTION_ERROR)
if show_verbose:
click.echo()
# All passed
if output_json:
print(json_module.dumps(result, indent=2))
elif quiet:
print("ok")
else:
if show_verbose:
click.echo("=" * 60)
success("All validation checks passed!")
sys.exit(EXIT_SUCCESS)
if __name__ == "__main__":
main()
"""Shared utilities for Jira CLI scripts."""
from .client import LazyJiraClient, get_jira_client, is_account_id
from .config import (
get_auth_mode,
load_config,
load_env,
load_profiles,
profile_to_config,
resolve_profile,
validate_config,
)
from .output import extract_adf_text, format_json, format_output, format_table
__all__ = [
"get_jira_client",
"LazyJiraClient",
"is_account_id",
"load_env",
"load_config",
"load_profiles",
"resolve_profile",
"profile_to_config",
"validate_config",
"get_auth_mode",
"format_output",
"format_json",
"format_table",
"extract_adf_text",
]
"""Stdin helpers for Jira CLI scripts that accept piped input.
This module is the input-side companion to :mod:`lib.output`, which
reconfigures ``stdout`` / ``stderr`` to UTF-8 on Windows at import time
(see PR #61). On the input side we cannot rely on auto-reconfiguration —
``sys.stdin``'s text-mode decoder is set up at interpreter startup and
honoured by every ``sys.stdin.read()`` call, so the only reliable fix is
to read through our own UTF-8 decoder wrapped around ``sys.stdin.buffer``.
"""
import sys
# === INLINE_START: input ===
def read_stdin_utf8(max_chars: int | None = None) -> str:
"""Read piped stdin as text, forcing UTF-8 regardless of host locale.
Args:
max_chars: Optional cap on the number of *characters* to read from
stdin. ``None`` reads until EOF. The cap counts decoded
characters (not bytes), matching the semantics of the
``sys.stdin.read(n)`` this helper replaces.
Returns:
The decoded text, with universal newline translation applied
(``\\r\\n`` / ``\\r`` → ``\\n``).
Raises:
UnicodeDecodeError: if the stdin bytes are not valid UTF-8 (e.g.
binary data accidentally piped in, or a file in a non-UTF-8
encoding such as UTF-16 / Windows-1252).
Why this exists
---------------
``sys.stdin.read()`` is a *text-mode* read — it decodes the underlying
bytes with whatever encoding Python picked for stdin at interpreter
startup. On Linux / macOS that is almost always UTF-8. On Windows it
defaults to the system codepage (cp1252, cp850, …) unless the user
has explicitly set ``PYTHONIOENCODING=utf-8`` or ``PYTHONUTF8=1`` in
the environment.
When a Windows shell user pipes a UTF-8 file in
(``cat file.txt | jira-comment add PROJ-123 -``), every UTF-8 byte
happens to also be a valid cp1252 character. The text-mode decode
*succeeds* with garbage characters (e.g. ``ü`` ``\\xc3\\xbc`` →
``ü``), the script re-encodes the garbage as UTF-8 to POST to the
Jira REST API, and Jira faithfully stores the mojibake.
We rebuild the text wrapper ourselves: ``io.TextIOWrapper`` around the
raw ``sys.stdin.buffer`` with ``encoding="utf-8"`` pinned. This forces
UTF-8 no matter the host codepage, while keeping the two text-mode
properties callers rely on:
* **Character-based capping.** ``read(max_chars)`` returns whole
characters, so it never splits a multi-byte UTF-8 sequence at the
cap boundary (which would raise a spurious ``UnicodeDecodeError`` and
mask the real "input too large" condition). The cap also stays a
character count, matching the ``len(text) > max_size`` checks at the
call sites.
* **Universal newlines.** ``\\r\\n`` is translated to ``\\n`` exactly as
the original text-mode read did, so Windows line endings don't leak
into Jira content.
``detach()`` releases ``sys.stdin.buffer`` without closing it, so the
wrapper's garbage collection can't tear down the process's stdin.
See also: PR #61 (April 2026) which fixed the sibling problem on
``stdout`` / ``stderr`` and motivated the duplicate-Jira-comment
incident.
"""
import io
wrapper = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8")
try:
if max_chars is None:
return wrapper.read()
return wrapper.read(max_chars)
finally:
wrapper.detach()
# === INLINE_END: input ===
"""JQL helper utilities.
Keep all escaping/quoting logic centralized so callers don't hand-roll
f-string JQL snippets.
"""
def jql_escape(value: str) -> str:
"""Escape a value for use inside a double-quoted JQL string literal."""
return value.replace("\\", "\\\\").replace('"', '\\"')
Related skills
How it compares
Pick jira-communication over generic PM skills when the task requires live Jira issue CRUD and workflow transitions from an agent session.
FAQ
What Jira actions does jira-communication support?
jira-communication supports creating issues, updating fields, posting comments, and transitioning workflow states so agent implementation sessions stay tied to Jira tickets and acceptance criteria.
When should developers use jira-communication?
Use jira-communication when a coding agent should open subtasks, document implementation progress on existing ticket keys, or move issues across sprint states after code review or CI passes.