jira-communication
Use when handling Jira issues, sprints, boards, links, fields, worklogs, attachments, or users, or on any Jira intent without a key ("create/find a ticket", "pick a project"). Auto-triggers on Jira URLs and issue keys (PROJ-123). Also use when MCP Atlassian tools fail or are unav
Install
npx skills add https://github.com/netresearch/jira-skill/tree/main/skills/jira-communication
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install netresearch-jira-skill@llmmart
git clone https://github.com/netresearch/jira-skill.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole netresearch/jira-skill collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
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 — use the matching verb.
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, tempo-account.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.
Posting wiki markup rewrites and checks it first
Every --comment and --description option that writes wiki markup runs three gates before the write, all on by default, because text that renders wrong is silent — the API returns 2xx either way. That is all seven: jira-comment.py add/edit, jira-transition.py do --comment, jira-transition.py path --comment, jira-worklog.py add --comment, and the --description of jira-create.py issue and jira-issue.py update. A body smuggled in through --fields-json is not gated — that option writes raw fields by design. jira-version.py writes two --description fields that are NOT gated (create and update); whether Jira renders a version description as wiki markup at all is unverified, and its help string claiming it does may simply be wrong.
- Dashes that Jira would render as strikethrough are escaped.
\-prints as a plain hyphen, so the posted text reads as written; stderr names how many lines changed and shows the first five. (The one shape where the escape is visible is two macros written against each other with no space — the dash can land inside a link target. Ordinary prose does not reach it.)--no-auto-escapekeeps the markup verbatim — but on its own it does not post a deliberate-strikethrough-: the lint and the render check each still refuse the span. Use--no-auto-escape --forcefor that. - The markup and the ticket language are linted. Block tags used inline (
{code},{noformat},{quote},{panel}are block-level), unbalanced tag counts, and German prose on an English-only project each abort the write.--forceturns the findings into warnings and posts anyway. - The text is rendered by the instance and refused if it comes back struck through. This costs one API call per post and catches what no local check can — an autolinked issue key creates a boundary that exists only on an instance where that key resolves. A resolved issue's key, which Jira draws struck through as status styling, is not reported.
--no-preflightskips it; an unreachable renderer warns once and posts anyway.
--force posts despite any of the three. The flags are spelled the same on each command. Under --dry-run the escape and the lint still run — the preview shows the text a real write would post — while the render call does not. See references/comments.md for the details.
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-comment.py add PROJ-123 "Comment text" --no-auto-escape --force # deliberate -strikethrough-
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
Transitions:
listshows each transition's id and what its screen requires; pass the id todo— a name or a target status is not always unique, and an ambiguous one is refused rather than guessed. Terminal transitions: pass--resolution <value>(Done,Won't do); if rejected ("cannot be set"), retry without it —references/intent-verbs.md. Versions: readreferences/versions.mdbeforejira-version.py. Mentions: posting commands verify[~username](miss → suggestions);get/workprint usernames (references/fields-and-users.md).
Related Skills
jira-syntax: descriptions/comments use Jira wiki markup, not Markdown.
No editorializing
State what happened, not how good it is — references/no-editorializing.md.
References
references/jql-quick-reference.md,references/jql-cookbook.mdreferences/multi-profile.md—--profilereferences/troubleshooting.md— auth, 401/403references/issue-editing.md— edit, delete, clear fields,--fields-jsonreferences/creation.md— create,--parent, fields, admin-scope (project,tempo-account.py)references/comments.md— edit, delete, lint, body via-references/worklog.md—--started, ranges,--tempo-account,deletereferences/attachments.md— upload, downloadreferences/links.md— linksreferences/agile.md— sprints/boardsreferences/no-editorializing.md— no self-praisereferences/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— 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.
Files (jira-skill)
-
evals
-
evals.json 6.6 KB
[ { "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" } ] }, { "name": "create_ticket_from_intent_without_key", "prompt": "We just decided on this work - please create a Jira task in project ACME titled 'Document the new export API'. I don't have an issue key yet.", "assertions": [ { "type": "tool_use", "tool": "Bash", "pattern": "jira-create\\.py\\s+issue" }, { "type": "content", "pattern": "(--type|--issuetype).*(Task|task)" }, { "type": "content", "pattern": "ACME" } ] } ]
-
-
references
-
agile.md 1.8 KB
# 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 ```bash # 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 scrum ``` ## Sprints ```bash # 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 916 ``` ## Assigning 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>`. ```bash # 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.md 3.9 KB
# 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 ```bash # 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" done ``` `jira-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). ```bash # 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.pdf ``` The output location is the **second positional argument** (`OUTPUT_FILE`), not an option. There is **no `--output-dir` flag** on `download` — passing one fails with `Error: No such option: --output-dir` (exit 2). To choose a directory, either include it in the `OUTPUT_FILE` path (as above) or use `download-all --dir <dir>` (the `--dir` flag exists only on the `download-all` subcommand, not on `download`). ### Verify the download succeeded `jira-attachment.py download` carries Jira authentication (PAT via `Authorization: Bearer`, or Cloud basic auth) and refuses to save a redirect/login body as the file, exiting non-zero on failure. When scripting, always check the command's exit status to detect failures: ```bash out=./attachments/report.pdf uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-attachment.py download "$url" "$out" || { echo "ERROR: download failed" >&2 exit 1 } ``` An unauthenticated or redirected fetch (e.g. a hand-rolled `curl`/`wget` that lands on the login page) yields a bad or empty file — but rely on the command's exit status, not a size test, to detect it: a size check masks the script's real error and falsely fails on legitimate 0-byte attachments. ## Download 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. ```bash # 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-run ``` ## Safety guarantees - **Path traversal**: output paths are constrained to the current working directory — the script rejects targets that resolve outside cwd. `cd` to the target directory before downloading. `download-all` additionally 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.md 10.4 KB
# 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 ```bash # 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-123 ``` The JSON output has a top-level `comments` array; each entry has `id`, `author.displayName`, `body`, and `updated`. ### `list` shows ten comments by default — pass `--limit 0` when you are reading, not harvesting ```bash # The whole history, paginated for you uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py list PROJ-123 --limit 0 ``` Ten is the right default for grabbing a recent comment ID. It is the wrong default for answering a question about what a ticket says, and the difference is invisible in the answer: a busy ticket carries its decisions in the middle of its history, and the last ten comments are the part that agreed with you. When the read informs a claim — a QA verdict, a status report, "nobody mentioned X" — use `--limit 0`. A truncated read now says so on **stderr** as well as in the table, so the notice survives a pipe and appears under `--json` and `--quiet` too. If you see `⚠ PROJ-123: showing 10 of 163 comments`, the answer you are about to give is based on 10. ### Never put a line filter between `list` and your eyes `| head`, `| tail`, `| grep`, `--max-count` — each of them cuts a comment body mid-sentence and drops whole comments silently, and what is left looks like a complete answer. "X does not appear in this ticket" after a truncated read is a statement about the cut, not about the ticket; it has been wrong in exactly that way, in a public comment that someone else had to correct. Use the tool's own knobs instead, which cut where the data says to cut rather than where the terminal does: ```bash # Shorten every body to N characters, keeping all comments and their metadata uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py list PROJ-123 --limit 0 --truncate 200 # Or select deliberately, in a structured way uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-comment.py --json list PROJ-123 --limit 0 \ | jq -r '.[] | select(.author.name == "someone") | .body' ``` For a body too large to read in one go, write it to a file and read windows of it — the file keeps the whole thing while you look at part of it, which is the property a pipe destroys. ## Edit an existing comment ```bash # 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 ```bash # 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 594276 ``` Non-interactive: no confirmation prompt, no stdin — `--dry-run` is the preview, the real call deletes on the spot (no `echo y |` needed). Deleting 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: ```bash 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 The three gates described here — escape, lint, render preview — run on every `--comment` and `--description` that writes wiki markup, not only on comments: `jira-comment.py add`/`edit`, `jira-transition.py do --comment`, `jira-transition.py path --comment`, `jira-worklog.py add --comment`, and the `--description` of `jira-create.py issue` and `jira-issue.py update`. Seven surfaces, and the three flags (`--force`, `--no-auto-escape`, `--no-preflight`) are spelled the same on each. The text below says "the body"; a description is checked identically. Two things are outside: a field written through `--fields-json`, which bypasses every gate by design, and the `--description` of `jira-version.py create`/`update`, where it is unverified whether Jira renders the field as wiki markup at all. `jira-transition path` IS gated; only the placement differs — it checks the comment before the FIRST transition rather than the final one it rides on, because a walk that aborted halfway would otherwise leave the issue in a status nobody chose. Under `--dry-run` the escape and the lint still run, so the preview prints what would be posted; only the render call is dropped. They 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 tag mentions as `\{code\}`. Override with `--force` (findings are then printed as warnings). Before posting, they also ask the instance how it will render the text (`POST /rest/api/1.0/render`, the endpoint behind Jira's own preview button) and refuse to post anything that comes back struck through. This catches what no local model can: Jira substitutes autolinked issue keys before text effects run, so `OPS-899-x und zu- und` is struck on an instance where OPS-899 exists and clean on one where it does not, because the substituted link creates the opener boundary. A link to a resolved issue is not counted: Jira draws its key in `<del>` inside the link as status styling, which is not a text effect, and the check unwraps exactly that shape before looking for struck spans. `--force` posts anyway; `--no-preflight` skips the call. An unreachable renderer, or a Cloud instance (the endpoint is Server/DC only), prints one warning and gets out of the way — it never blocks a post. Dashes that Jira would render as a strikethrough span are **repaired rather than reported**: they are escaped before posting and print on stderr which lines they changed. `\-` prints as a plain hyphen, so the posted text reads as written. `--no-auto-escape` keeps the markup verbatim, but does not by itself post text with a live span — the lint and the render check each refuse it independently, so a deliberate strikethrough needs `--no-auto-escape --force`. The grammar is measured against a live Jira Server 9.12 renderer — the recorded cases and the re-recording script live in the source repo (netresearch/jira-skill), not in the standalone skill package — which matters because the shape is counter-intuitive in both directions: `journalctl -b -p crit` is safe (a dash leading a word cannot close a span), while `{{nr-pforum}}-Extensions ... zu- und abschaltbar` is struck through end to end. See the quick reference in the `jira-syntax` skill for the full rule. The same lint carries a **ticket-language reminder**. Some projects are English-only by team convention (the rule and the project list live in the consuming team skill; the check ships with `NRS`, `NRT`, `SRV*`, `IO*`, `LIC`, `PO`), and language drift is invisible in review because such tickets often already contain German from quoted mails. When a comment on one of those keys reads as German prose — five or more distinct German function words, which a loanword or a short quoted fragment stays below — the lint says so and names the markers it found. The scan reads the whole body and cannot tell a quote from authored text, so a comment carrying a *substantial* German quote is reported too; `--force` is how you post it verbatim. Other projects, customer ones included, are never touched by this check. Every command that posts mention-capable wiki markup runs the same mention gate: `jira-comment add`/`edit`, `jira-transition do --comment`, `jira-worklog add --comment`, and the `--description` of `jira-create issue` / `jira-issue update`. Each `[~username]` is verified against Jira before posting (an unverified mention renders as dead text and notifies nobody); an unknown username aborts with candidate suggestions rendered in the form that actually notifies (`[~name]` on Server/DC, `[~accountid:...]` on Cloud, where a plain `[~username]` can never notify and is always flagged). Mentions inside `{code}`/`{noformat}` blocks and backslash-escaped literals (`\[~...]`) are ignored — quoting a log line does not trip the gate. Auth or transport failures abort with the real error, never as "unknown user". Skip with `--no-verify-mentions`. ## Verify rendering after posting A 2xx on `add`/`edit` proves the write landed, not that the markup renders as intended — Jira renders wiki markup server-side. The rendered HTML is the only proof: ```bash curl -s -H "Authorization: Bearer $JIRA_PERSONAL_TOKEN" \ "$JIRA_URL/rest/api/2/issue/<KEY>/comment/<id>?expand=renderedBody" | jq -r '.renderedBody' ``` Grep it for what you fear: `<del>` means something parsed as strikethrough, a literal `\` means a backslash escape reached the reader, and `-` is a correctly escaped dash. This is a manual check — nothing runs it for you. With the pre-flight above left on, a `<del>` here should not happen; seeing one means it was skipped, forced, or that the renderer changed its mind between the preview and the write. Run this after editing any markup-sensitive comment; verified against Jira Server 9.12. ## Comment verbosity: depth for the failing path only Working-path checks get ONE summary line at most; the non-working path gets the depth; obvious/derivable steps are cut entirely. A human reader cannot filter long tables that mostly say "this works" — verbose investigation comments cause overload and force the reader to re-derive what mattered. ## Consolidate progress updates — edit, don't append When iterative work on one issue produces multiple status updates, edit the prior comment instead of adding a new one. Watchers and QA reviewers are notified per comment and must wade through progress chatter to find the current state. Reserve new comments for genuinely separate story beats that build on (not restate) earlier ones. -
creation.md 2.6 KB
# 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` ```bash # --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-100 ``` When `--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 ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-create.py issue PROJ "User-reported bug" \ --type Bug --reporter jane.doe ``` Value is the accountId on Cloud, the username on Server/DC. Resolve via `jira-user.py search` (see `fields-and-users.md`). ## Components ```bash 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 ```bash 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. ## Admin-scope commands: `jira-create.py project` and `tempo-account.py` ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-create.py project PROJ "Project Name" --from-project OTHERPROJ --lead jdoe uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/tempo-account.py account link 42 PROJ --default ``` These exercise Jira Administrator / Tempo Administrator rights on whichever PAT is configured. On Jira Server a PAT carries the full permission set of the user who created it, so there is no separate, narrower credential for this — check before running these against an instance where that scope is not expected. -
fields-and-users.md 6.8 KB
# 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 ```bash # 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 me ``` Useful 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. Every posting command (`jira-comment add/edit`, `jira-transition do --comment`, `jira-worklog add --comment`, the `--description` of `jira-create`/`jira-issue update`) therefore verifies each `[~username]` at post time and aborts with suggestions on a miss (`--no-verify-mentions` skips). For ticket participants no lookup is needed at all: `jira-issue get/work`, `jira-comment list` and `jira-worklog list` print the technical identifier in parentheses next to each display name (Server/DC username, or `accountid:<id>` on Cloud) — copy it into the mention. For anyone else, `jira-user.py search "<display name>"` resolves it. 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.) ## Assignee — assign and unassign ```bash # Assign to a user (or to self) uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 --assignee john.doe uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 --assignee me # Unassign — clear the assignee (no dedicated flag; use the field directly) uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 --fields-json '{"assignee": null}' ``` There is no `--unassign` flag: `--assignee` only *sets* a value. To clear the assignee, pass `{"assignee": null}` via `--fields-json` (works on Server/DC). On some instances a `null` assignment can revert to a project default rather than "Unassigned" — verify with `uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py get PROJ-123` afterwards (look for `Assignee: Unassigned`). ## Custom fields ```bash # 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 ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-fields.py types PROJ ``` Prints 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. ## Confirm the TYPE before you read the value The `id` tells you which field to ask for; it does not tell you what comes back. A field whose name reads like a number often is not one, and the mismatch surfaces as a `TypeError` in the caller rather than as a Jira error: ```bash # schema.type for one field — the half that decides how to parse the value uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-fields.py --json search "budget" \ | jq -r '.[] | "\(.id)\t\(.schema.type // "—")\t\(.schema.items // "-")\t\(.name)"' ``` What the common types actually deliver in `fields`: | `schema.type` | Value shape | Reading it | |---|---|---| | `number` | `3958.51` | `float(v)` | | `string` | `"Q3/2026"` | as is | | `option` | `{"self":…,"value":"> 25.000 EUR","id":"10026"}` | `v["value"]` — **never** `float(v)` | | `array` | list of whatever `schema.items` names | iterate, then read each element by its `items` type — `labels` is `items=string`, a multi-select is `items=option` | | `user` | Server/DC: `{"name":…,"key":…,"displayName":…}` · Cloud: `{"accountId":…,"displayName":…}` | `v["name"]` on Server/DC, `v["accountId"]` on Cloud — this skill targets Server/DC | | `account` (Tempo) | `{"id":…,"key":…,"name":…}` on read | asymmetric: writing takes the **account id** as a bare string (`"208"`), and the key or name is rejected with `Account id 'null' is invalid` | The case that motivates this: a field called `Vertrieb: Budget` on jira.netresearch.de turned out to be an `option` with three size brackets rather than an amount. Reading it with `float()` raised `TypeError` and killed the collector that held it — and because no issue in the queried set carried a value at first, it shipped and waited. A single `schema.type` lookup before the first read costs one call. Corollary: a field that is empty everywhere you looked is not evidence of its type. Query one issue that actually has a value (`"<Field>" is not EMPTY`) and look at the raw JSON. ## Jira Server config reads: three access tiers — "no REST to SET" does not mean "no way to READ" For scheme assignments, mail handlers, components, categories on Jira Server, try in order: 1. **REST with PAT** — workflow/notification/permission/issue-type schemes + associations, components, versions, watchers, role actors, category, lead (`/rest/api/2/project/<KEY>/<resource>`, `/rest/api/2/<scheme>/<id>/associations`). 2. **Project-level admin HTML with PAT** — screen scheme, issue type screen scheme, anything on `/plugins/servlet/project-config/<KEY>/…`; scheme names are extractable from the returned HTML. 3. **Site-level admin HTML** (`/secure/admin/…`) — WebSudo-gated: PAT alone gets a 200 with `<title>Administrator Access…</title>` and a password form. Genuinely needs a human. **Detection rule:** a body containing "Administrator Access" means you hit WebSudo, not the data — never trust byte count alone. Only declare "manual UI check needed" after tiers 1 AND 2 both failed. -
intent-verbs.md 13.1 KB
# Intent verbs `jira-issue.py work / qa / qa-fail / act` — single-call context bundles for the four common intents. ## Access — this skill IS the Jira path Reach for these scripts on **any** Jira intent, even with no issue key — "create a ticket in the right project", "find the ticket for X", or just naming Jira. You do not need a key to start (`jira-search.py` finds existing issues by JQL; `jira-create.py` opens new ones; the right project comes from your own organisation-specific project-routing conventions). Do **not** conclude "no Jira access" from an MCP connector's scopes. A Confluence/Atlassian MCP connector — e.g. a cloud `*.atlassian.net` connector limited to Confluence — is a **separate system** from these scripts, which talk to Jira Server/DC (or Cloud) directly via `~/.env.jira`. A connector being Confluence-only says nothing about Jira reachability. If Jira genuinely seems unreachable, run `jira-setup.py` to check config — and tell the user up front, immediately, rather than burying it in options. ## 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 ```bash 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 transitions ``` `jira-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` — **pass `--resolution <value>`** when executing this transition, unless the screen rejects it (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". Pass `--resolution` with the value that matches the outcome wherever the transition screen accepts it: | 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` | ```bash 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 Duplicate ``` #### When the screen rejects `--resolution` Not every workflow puts the resolution field on its terminal transition screens. Where it is absent the transition fails outright: ``` jira-transition.py do KEY "Deployed to PROD" --resolution Done → Field 'resolution' cannot be set. It is not on the appropriate screen, or unknown. ``` **Do not pre-screen with `expand=transitions.fields` — it under-reports.** A workflow whose transitions all came back *without* a `resolution` key in `GET /issue/KEY/transitions?expand=transitions.fields` still accepted `--resolution Done` on its Close transition and set the field (measured on Jira DC 9.12, 2026-08: every transition of the workflow reported `resolution` absent, the sibling ticket's history showed resolution set in one Backlog → Closed hop, and passing `--resolution` reproduced exactly that). Absence in the expand is therefore no evidence the transition will reject the field. Attempt the transition **with** `--resolution`; the error message above is the only reliable rejection signal, and only after seeing it fall back to the numbered options below. Setting it afterwards via `jira-issue.py update KEY --fields-json '{"resolution": {"name": "Done"}}'` fails with the same message — though for a different screen: the transition rejection is about the *transition* screen, this one about the issue's *edit* screen. See *"Field 'xyz' cannot be set"* in `troubleshooting.md`. The transition itself is atomic — the whole POST is rejected, so nothing half-applies and the issue keeps its previous status. Work through the options in order: 1. **Check whether another transition carries the field.** `jira-transition.py list KEY` may offer a different terminal transition whose screen does include `resolution`; prefer that one. 2. **Otherwise retry without `--resolution`** and let the ticket land with an empty resolution. On workflows built this way (deployment pipelines with several terminal-looking gates) the resolution is applied by a workflow post-function at a later step, not by the transition you are running. 3. **Verify rather than assume.** Once the workflow has reached its true terminal status, confirm the post-function actually fired: ```bash # One ticket jira-search.py query "key = PROJ-123 AND resolution is EMPTY" # Whole project — audit everything that closed without a resolution jira-search.py query "project = PROJ AND statusCategory = Done AND resolution is EMPTY" -f key,status ``` A hit means the resolution is genuinely missing — say so to the user instead of treating step 2 as success. (`resolution is EMPTY` is the "unresolved" mapping in `references/jql-cookbook.md`'s phrase table; note its *"Resolution helpers"* heading is about resolving fuzzy **names**, not this field.) Available resolution names vary by Jira instance. Query yours with: ```bash 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)]" ``` ### Fields the screen requires beyond resolution A transition screen is not limited to `resolution` — `list`'s `Requires` column can name any field, including ones that look pre-filled already, e.g. `summary`. `do` rejects the attempt up front rather than posting a payload the API would reject anyway: ``` jira-transition.py do PROJ-123 371 ✗ Transition 'Close' requires: summary ``` Pass it with `--fields-json`, same shape as `jira-issue.py update`: ```bash jira-transition.py do PROJ-123 371 --resolution Done \ --fields-json '{"summary": "Unchanged summary, resubmitted because the screen demands it"}' ``` Observed on jira.netresearch.de (OPS project, 2026-09): a `Backlog → Closed` "Close" transition required `summary` on its screen even though the value was not changing — the transition screen re-submits whatever fields it lists, it does not carry the issue's current value forward automatically. `--fields-json` accepts any field the screen names this way, not only `summary`. ### 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. ```bash 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 step ``` It 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`: ```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): ```bash 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 - `--json` Structured payload (`comments` is always a list of comment dicts; verb-specific keys like `reject_transition`, `handover_transition`, `implementer` for context) - `--quiet` Issue 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): ```bash jira-issue.py qa-fail NRS-4412 ``` Returns: 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 <selector>` takes a transition ID, a transition name, or a target status name. Prefer the ID from `jira-transition.py list`: names carry emoji prefixes on some instances (`✅ Resolve`, `❌ QA failed`), two transitions can share a name up to that emoji, and two can share a target status — an ambiguous selector is refused with its candidates rather than resolved to a guess. On mismatch the error lists the available transitions with their IDs. `jira-issue.py act KEY` shows them up front. -
issue-editing.md 8.8 KB
# 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: ```bash 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). ## Clearing a field Typed flags cannot clear a field — `--assignee ""` contributes nothing and the CLI reports `✗ No fields specified for update`, so the field keeps its old value and nothing signals that the clear was a no-op. Pass an explicit `null` via `--fields-json`: ```bash # Unassign (back to the team queue) uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \ --fields-json '{"assignee": null}' # Clear a due date and a custom user-picker field in one call uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \ --fields-json '{"duedate": null, "customfield_12608": null}' ``` Not every field can be cleared this way — the field must be on the issue's edit screen. Assignee on a transition-only screen needs the dedicated `/rest/api/2/issue/KEY/assignee` endpoint, and `parent` cannot be cleared at all (see the sub-task note below). ## `--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. There is **no** `--field` flag; `--fields-json` is the only generic setter: ```bash # 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`. **Epic Link is edit-screen-only.** On Jira Server/DC the Epic Link field (look it up with `jira-fields.py search epic`, e.g. `customfield_10580`) is usually **not on the create screen** — passing it to `jira-create.py … --fields-json` fails with *"Field … cannot be set. It is not on the appropriate screen, or unknown."* Create the issue first, then set the epic with `jira-issue.py update KEY --fields-json '{"customfield_10580": "PROJ-1"}'`. ### Multi-line wiki-markup bodies (Deployment Information, UAT fields, …) `--fields-json` takes a raw JSON string with no `-`/stdin support (unlike `--description`), so hand-escaping a multi-line wiki-markup body — headers, `{{code}}` spans, embedded newlines — is error-prone. Build the payload with `jq` instead of inline JSON: ```bash jq -Rs --arg fid "customfield_11488" '{($fid): .}' body.txt > payload.json uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 --fields-json "$(cat payload.json)" ``` `jq -Rs` reads the file as one raw string, preserving newlines, and `--arg fid` keeps the field ID out of the JSON body — no manual quote/newline escaping. ## Array-valued fields: replace vs incremental updates Every array-valued field — `labels`, `fixVersions`, `versions` (Affects Version/s), `components` — is **replaced wholesale** by whatever you send. `jira-issue.py update` has no append path: it always sends a plain `fields` set (`update_fields.update(extra_fields)` → `update_issue_field`). Jira's REST API *does* have an append verb — `{"update": {"fixVersions": [{"add": {"id": "…"}}]}}` — but the scripts do not expose it, so a payload carrying one entry leaves the issue with exactly that one entry. ### Labels — the one field with incremental flags `jira-issue.py update` supports three modes for labels: - `--labels a,b,c` replaces the full label set. - `--add-label` / `--remove-label` incrementally update labels without wiping unrelated tags. - Do **not** combine `--labels` with `--add-label` / `--remove-label` in 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. ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \ --add-label backend --add-label urgent,frontend --remove-label stale ``` ### `fixVersions` / `versions` / `components` — read, extend, send whole These have **no** `--add-*` / `--remove-*` equivalent; they are only reachable through `--fields-json`, which is a plain replace. Adding one version therefore means sending the existing entries *plus* the new one: ```bash # Wrong — clobbers every fix version already on the issue, silently and with ✓ exit 0 uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \ --fields-json '{"fixVersions": [{"id": "10123"}]}' # Correct — read the current ids first uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py --json get PROJ-123 --fields fixVersions \ | jq -r '.fields.fixVersions[].id' # → 10098 # …then send the union uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py update PROJ-123 \ --fields-json '{"fixVersions": [{"id": "10098"}, {"id": "10123"}]}' ``` The failure is silent — the call reports `✓` because the write succeeded; only the *intent* (add, not replace) was lost. Re-read the field afterwards when it matters. `{"name": "1.4.0"}` works in place of `{"id": ...}`, but IDs survive a version rename and names do not (see `versions.md`). To move many issues onto a different version at once, prefer `jira-version.py merge` / `delete --move-fix-to` over per-issue `--fields-json` edits. ## Setting a custom reporter `jira-issue.py update` has no `--reporter` flag; the reporter on an existing issue is changed through `--fields-json`: ```bash # 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 ```bash # 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: ```bash # 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 Task ``` This works between two standard types (or two sub-task types). **Converting a sub-task ↔ a standard issue cannot be done via the API.** `jira-move.py … --issue-type Task` on a *sub-task* fails with *"Issue type N is not a sub-task but a parent is specified"* — the parent can't be cleared through the edit endpoint, and `jira-issue.py update --fields-json '{"parent": null, ...}'` also fails. It is a **UI-only** operation: open the issue → **More → Convert to Issue** (this is *not* the "Move" action). After converting in the UI the issue becomes a standard type and can take an Epic Link (above). -
jql-cookbook.md 7.9 KB
# JQL 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 worked - `Done` — 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:** ```text issuetype = Bug AND statusCategory != Done AND updated < -14d ``` **Step 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` ```text assignee = currentUser() AND updated < -14d ``` No resolvers needed. No ambiguity worth surfacing. --- ## Worked example 3: *"how long has PROJ-123 been in Review?"* Not a JQL question — use the changelog: ```bash 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.md 6.3 KB
# 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): ```bash # 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` ```jql status CHANGED FROM "Open" TO "In Progress" BY currentUser() AFTER -7d ``` ## Functions ### 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 ```jql assignee = currentUser() assignee = "john.doe" assignee IS EMPTY assignee IN membersOf("developers") ``` ### By Status ```jql status = "In Progress" status IN (Open, "To Do", "In Progress") status != Done status WAS "Open" status CHANGED FROM "Open" TO "In Progress" ``` ### By Date ```jql created >= -7d updated >= startOfWeek() due <= endOfMonth() resolved >= "2024-01-01" created >= startOfMonth(-1) AND created < startOfMonth() ``` ### By Sprint ```jql sprint IN openSprints() sprint IN closedSprints() sprint = "Sprint 42" sprint IS EMPTY ``` ### By Text ```jql text ~ "error message" summary ~ "login bug" description ~ "timeout" comment ~ "workaround" ``` ## Combining Conditions ```jql 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 ASC ``` ## Keywords | 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 uses `username` (e.g. `assignee = "john.doe"`). The `currentUser()` function works on both. - Functions like `currentUser()`, `membersOf()`, date functions, and sprint functions work on both platforms. ## Sources **Cloud:** - [JQL Operators](https://support.atlassian.com/jira-software-cloud/docs/jql-operators/) - [JQL Functions](https://support.atlassian.com/jira-software-cloud/docs/jql-functions/) - [JQL Keywords](https://support.atlassian.com/jira-software-cloud/docs/jql-keywords/) **Server/Data Center:** - [JQL Operators (Server/DC)](https://confluence.atlassian.com/jirasoftwareserver/advanced-searching-operators-reference-939938753.html) - [JQL Functions (Server/DC)](https://confluence.atlassian.com/jirasoftwareserver/advanced-searching-functions-reference-939938746.html) - [JQL Keywords (Server/DC)](https://confluence.atlassian.com/jirasoftwareserver/advanced-searching-keywords-reference-939938757.html) -
links.md 7.7 KB
# 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: ```text Created: IOS-18 causes NRS-878 (link-type: Cause) ``` ## Issue-to-issue links ```bash # 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 10042 ``` **Link 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); on a name miss it falls back to the outward/inward verbs and then to a unique substring across name + verbs, so Cloud-style names resolve on Server/DC too (`Relates` → `Relation` via the `relates to` verb, `blocks` → `Blockade`). An ambiguous or non-existent input still fails, listing the candidates. ## 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) ```bash 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) ```bash 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) ```bash 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 ```bash # 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-error ``` The 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 ```bash # 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.txt ``` Get the IDs from `jira-link list <ISSUE-KEY> --json` first. ### `invert` — fix a backwards link in one shot ```bash # 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 10042 ``` Destructive — 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`. ## Web links (links to external URLs) ```bash # 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 42 ``` Web links are scoped per issue; the same URL on two issues is two independent web links. -
multi-profile.md 3.5 KB
# 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: ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py --profile cloud get WEB-123 uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-search.py --profile server query "project = OPS" ``` ## `~/.jira/profiles.json` Format ```json { "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: ```bash echo "server" > /path/to/my-project/.jira-profile ``` When 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 --migrate` to convert an existing `~/.env.jira` file into a profile in `~/.jira/profiles.json`. - **`--all-profiles`**: Use `jira-validate.py --all-profiles` to 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. -
no-editorializing.md 2.2 KB
# No editorializing — inform, don't sell (tone, not wordlist) Applies to every written artifact: commit messages, PR/MR descriptions, review comments, issue/ticket text, chat — and code comments, docstrings, documentation, README and changelog files. Editorializing is a matter of **tone and intent, not specific words** — no banned-word list catches it, and the same word can be fine or not depending on whether it carries a fact. The failure is writing about *how good, clean, or careful the work is* instead of *what it does*. The reader has the diff and the artifact; anything that only flatters the work or reassures them adds nothing, and to a reviewer it reads as salesmanship — it provokes a counter-reaction before they reach the substance. Apply three tests before a sentence stays: 1. **Deletion** — remove the phrase. Did the reader lose a fact? If not, cut it. 2. **Subject** — is the sentence about the change, or about *you / your work* (its quality, your diligence)? The latter goes. 3. **Voice** — would a terse maintainer write this, or does it read like a cover letter? Two recurring failure modes: - **Announcing the expected.** Passing tests, clean linters, "documented", "no regressions", "works as expected" are the baseline — do not narrate them. State a check's status only to flag an *exception* (something knowingly failing or skipped). In a test/verification list, say what was *added or covered*, not that it is green. - **Self-praise and reassurance.** Grading your own output ("clean", "robust", "elegant", "foolproof", "tidy", "genuinely new", "production-ready"); framings that reassure ("the honest breaking change", "deliberately scoped, not hidden", "where it belongs"); and the diligence humble-brag ("I carefully…", "I made sure to…", "thoroughly tested"). These describe the author, not the change. Show the fact; drop the framing. (The words are only symptoms — judge by the three tests above, not by the word.) Use plain labels, not graded ones: "Breaking change", "Tests", "Limitations" — not "Tests (all green)" or "Breaking change (honest)". If a limitation's cause matters, it is already stated in the item. -
qa-gather.md 6 KB
# 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`](https://github.com/netresearch/peer-qa-review-skill)) 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. The description and every comment body are part of the text output, so no follow-up `jira-issue.py work KEY` is needed to read the ticket. ## Command ```bash 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 --no-body # metadata only uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-qa-gather.py PROJ-123 --json ``` Read-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. | | `--no-body` | off | Omit the description and the comment bodies from the text output (metadata-only shape; the comment count and URL extraction still cover every comment). No effect on `--json`. | | `--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, **current assignee** (or `Unassigned`), comment count, worklog count + total minutes 3. `Description:` — the full description, indented (omitted when empty, or with `--no-body`) 4. Structured issue links (`<type> → <key>: <summary>` for outward, `←` for inward), or `Issue links: none` 5. Web/remote links (`title: url`), or `Web/remote links: none` Sections 4 and 5 always print, including when empty. "None" is a reviewable fact — a related ticket mentioned in prose but never linked, or a merged MR with no web link, is a finding in its own right — whereas an omitted section reads as "not checked" and invites the reader to assume the links exist. The assignee is section 2 because claiming a ticket off a team queue depends on it: unassigned means claimable, someone else means it is already in flight, and yourself means you may be about to review your own work. 6. URLs extracted from prose, grouped by category: `merge_request`, `pull_request`, `pipeline`, `commit`, `tag`, `release`, `issue_link` 7. Sibling tickets in the same project, sorted by `updated DESC` 8. `COMMENTS (N total — chronological)` — every comment as `--- [YYYY-MM-DD HH:MM] Display Name (username) ---` followed by its body, the same rendering as `jira-issue.py work` (omitted when there are none, or with `--no-body`) Comments come last so the metadata stays at the top of the screen; the section is the full, paginated set (Jira's embedded block stops at 50 on Server/DC). ## JSON shape (with `--json`) Top-level keys (stable): - `issue_key` — string, the requested key - `issue` — full Jira issue dict from `client.issue()` with `expand=renderedFields` - `description` — raw `fields.description` (string on Server/DC, ADF dict on Cloud), `null` when empty — same shape as `jira-issue.py work --json` - `comments` — list of comment dicts, all pages (falls back to the embedded block from the issue payload if the paginated fetch fails, with a warning) - `worklogs` — list of worklog dicts - `worklog_total_seconds` — int - `assignee` — string account name, or `null` when unassigned (`null` is meaningful: an unclaimed queue ticket) - `assignee_display` — string display name, or `null` - `issue_links` — list (raw `issuelinks` from the issue) - `web_links` — list (from `get_issue_remote_links`) - `extracted_urls` — `{category: [url, ...]}` deduplicated, order-preserved - `siblings` — 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. - Paginated comment fetch fails → warning to stderr, the comments embedded in the issue payload (capped at 50) are used instead. - Exception messages are passed through `_sanitize_error()` to redact tokens / passwords / api keys before being printed. ## Companion runbook The [`peer-qa-review`](https://github.com/netresearch/peer-qa-review-skill) 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.md 11.6 KB
# 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. Also load it before building a `--json | jq` pipeline: the two most common failures there (stream pollution and payload shape) are documented below. ## Setup Validation Always start with: ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-validate.py --verbose ``` ### Exit 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 ```bash JIRA_URL=https://yourcompany.atlassian.net JIRA_USERNAME=your-email@example.com JIRA_API_TOKEN=your-api-token-here ``` ### Jira Server/Data Center ```bash JIRA_URL=https://jira.yourcompany.com JIRA_PERSONAL_TOKEN=your-personal-access-token ``` ### Option B: Environment Variables Export variables directly (useful in CI/CD or when credentials are managed externally): ```bash # 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-token ``` ## Common Errors ### "jq: parse error: Invalid numeric literal at line 1, column 10" **Cause**: `uv run` prints `Installed N packages in Xms` on a cold cache. uv writes that notice to **stderr**, so a plain `--json | jq` pipeline is unaffected — the line only reaches `jq` when stderr has been folded into the pipe: an explicit `2>&1 |`, a wrapper or CI step that combines streams, or an agent harness that captures merged output. Column 10 is the character after `Installed`, which is the fingerprint of this specific cause; the scripts themselves are not the source — warnings and errors go to stderr (`output.py:warning()` / `error()`) and `--json` mode suppresses `✓` lines in the commands you would pipe. (`output.py:success()` does print to stdout, and a few write paths call it unguarded — `jira-create.py project --bootstrap-issues` is one — so pipe write commands with care.) **Fix**: keep stderr out of a JSON pipe. Where the streams must stay merged, filter the notice: ```bash # Wrong — merged streams put uv's install notice on jq's stdin uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-board.py --json list --project PROJ 2>&1 | jq -c '.[]' # Correct — leave stderr on the terminal uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-board.py --json list --project PROJ | jq -c '.[]' # Correct — merged output is unavoidable, so drop the notice uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-board.py --json list --project PROJ 2>&1 \ | grep -v '^Installed' | jq -c '.[]' ``` A script's first invocation warms **its own** environment, so later calls to *that* script are silent. Each script warms separately — uv keys the environment per script, so warming `jira-issue.py` does not silence `jira-search.py` even though their PEP 723 dependency lists are byte-identical. The warm state lives in the uv cache, not the shell session, so it also survives across sessions. ### "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: ```bash # Wrong uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py get PROJ-123 --json # Correct uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py --json get PROJ-123 ``` ### "Cannot index array with string" (`--json` payload shape) **Cause**: the flag placement above is right but the jq path is wrong. `--json` emits a **bare array** for list-style subcommands — there is no `{"issues": [...]}` envelope to index. `jira-search.py` unwraps the API response itself — `results.get("issues", [])` — and hands the plain list to `format_output(..., as_json=True)`, which dumps it as-is. Shapes across the scripts (verify with `| jq -r 'type'` rather than assuming): | Subcommand | Top-level JSON | jq path | |---|---|---| | `search query`, `comment list`, `version list`, `board list`, `transition list`, `link list`, `link list-types`, `weblink list`, `worklog list`, `sprint list`, `fields search`, `user search` | array | `.[]` | | `issue get` | object | `.key`, `.fields.…` (comments live at `.fields.comment.comments`) | | `issue work / qa / qa-fail` | object | `.key`, `.comments[]` | | `issue act` | object | `.key`, `.transitions[]` | | `watchers list` | object — the one *list* subcommand that wraps its result | `.watchers[]`, `.watchCount` | | `jira-qa-gather.py KEY` | object (bundle, like `work`) | `.siblings[]`, `.comments[]`, `.worklogs[]` | **Fix**: index the array directly. ```bash # Wrong — exits 5 with: Cannot index array with string "issues" uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-search.py --json query "project = OPS" | jq -r '.issues[].key' # Correct uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-search.py --json query "project = OPS" | jq -r '.[].key' ``` Confirm any shape you are unsure of with `… --json <cmd> | jq -r 'type'` before building the pipeline on top of it. ### "No such option: -f" / "-n" (query options before the subcommand) **Cause**: The inverse of the error above — a *subcommand* option placed before the subcommand. `-f/--fields`, `-n/--max-results`, and `--order-by` belong to `query`, so they must come **after** the `query` token (before or after the positional JQL is fine), never before it. Global options (`--json`, `-q`) go before the subcommand. A stub that ignores argument order hides this — verify the ordering against the live tool. **Fix**: Put query flags after the `query` subcommand: ```bash # Wrong — -f before the `query` subcommand → "Error: No such option: -f" (exit 2) uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-search.py --json -f key,status query "project = OPS" # Correct — global flags before `query`; query flags after `query`, # either before or after the JQL both work uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-search.py --json query "project = OPS" -f key,status -n 500 uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-search.py --json query -f key,status -n 500 "project = OPS" ``` ### "Transition 'X' not available" **Cause**: the selector matched no transition offered from the issue's current status. `do` accepts a transition ID, a transition name, or a target status name — so this means none of the three matched, not that the wrong kind was passed. **Fix**: list what the issue actually offers, then pass the ID from the leftmost column: ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-transition.py list PROJ-123 uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-transition.py do PROJ-123 311 ``` ### "Transition 'X' is ambiguous" **Cause**: the name or target status matched more than one transition, and they are not interchangeable. **Fix**: pass the ID. Neither a name nor a target status is a reliable handle: - two transitions can share a **name** up to an emoji — `✅ QA` → Resolved beside `❌ QA` → Reopened, opposite outcomes; - two can share a **target** — `✅ Done` → Closed beside `✖ Close` → Closed, where only the second demands a resolution. `list` shows the ID, the target, and what each transition's screen requires. The ID is the only selector that means exactly one thing. ### "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"}`) **`resolution` is the common special case.** On workflows whose terminal transition screens omit the field, both `jira-transition.py do KEY "…" --resolution Done` and a follow-up `jira-issue.py update --fields-json '{"resolution": {"name": "Done"}}'` fail with this error. Retry the transition without `--resolution` — see *"When the screen rejects `--resolution`"* in `intent-verbs.md`. ## Debug Mode Add `--debug` for full stack traces: ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-issue.py --debug get PROJ-123 ``` ## Auth Mode Detection Scripts auto-detect auth mode: - If `JIRA_PERSONAL_TOKEN` set → Server/DC PAT auth - If `JIRA_USERNAME` + `JIRA_API_TOKEN` set → Cloud basic auth - URL containing `.atlassian.net` → Cloud mode Override with `JIRA_CLOUD=true` or `JIRA_CLOUD=false`. ## `{task}` inline checkboxes CAN be ticked via API — use the tasklist endpoint The `{task:id=NN}…{task}` checkboxes in Jira Server descriptions (maintenance tickets) store their state in a plugin database, not in the description text. They toggle fine with a PAT — but only via the **Task List** REST API: | Method | Path | Body | Result | | ------ | ---- | ---- | ------ | | `GET` | `/rest/tasklist/1.0/tasks/<id>` | (none) | XML `<checked>true\|false</checked>` | | `POST` | `/rest/tasklist/1.0/tasks/<id>/updateselection` | form `checked=true\|false` | HTTP 204 | An earlier version of this section claimed the boxes need a browser session. That conclusion came from probing the WRONG endpoint: `/rest/inline-tasks/1.0/task/<id>` does 302-redirect PATs to `login.jsp` — but that is a different plugin's route, not the one these checkboxes use (re-confirmed 2026-08-12: inline-tasks 302s while the tasklist POST answers 204 with the same PAT). Curl gotchas: a JSON body returns 415 and a `?checked=` query param is ignored — the form body (`--data-urlencode checked=true`) is authoritative, and `curl` exits 0 even on a 4xx, so judge success by `-w %{http_code}` == 204, not the exit code. Tick a box only when its work is verifiably done — the checkboxes are a progress signal, not a close-everything button. -
versions.md 7.6 KB
# 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 ```bash # 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 PROJ ``` ## Get ```bash # 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 --counts ``` ## Create ```bash 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-run ``` ## Update ```bash # 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-run ``` ## Release / unrelease ```bash # 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 10042 ``` ## Archive / unarchive ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py archive 10039 uv run ${CLAUDE_SKILL_DIR}/scripts/workflow/jira-version.py unarchive 10039 ``` ## Move ```bash # 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 First ``` ## Merge ```bash # 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 10042 ``` ## Delete ```bash # 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-run ``` ## Gotchas - **Version CRUD needs project-admin rights.** `create` fails with *"Project with key 'X' either does not exist or you do not have permission to create versions in it"* and `update` with *"You must have global or project administrator rights in order to modify versions"* when the token is a plain project member — even where that same token can happily edit issues and set `fixVersions` on them. The two permissions are unrelated: assigning an existing version to an issue is an issue edit, while creating or renaming one is a project-admin action. Check before planning a release flow around `create`/`update`, because the failure arrives mid-procedure. - **Plural field names only.** On issues, use `fixVersions` (Fix Version/s) and `versions` (Affects Version/s). The singular forms `fixVersion` and `version` silently no-op on create and give a confusing "field does not exist on screen" error on update. - **Renaming propagates to every issue automatically.** Issues store a version *reference* (by numeric ID), not a copy of its name, so `update <id> --name "5.2.2"` is immediately visible on every issue carrying that version — the ID stays put and only the name changes. **No per-issue `fixVersions` edit is needed afterwards.** JQL follows the new name immediately; the old name does not merely stop matching, it becomes **invalid** — `fixVersion = "<old name>"` fails with `The value '<old name>' does not exist for the field 'fixVersion'` (a non-zero exit, not an empty result set). Saved filters and dashboards keyed on the old *name* therefore break rather than silently returning nothing, so grep for it after a rename. This is what makes the rolling-placeholder pattern cheap: create one placeholder version ("next release"), collect issues on it during the cycle, then rename it into the real version number at release time. The instinct to bulk-edit the issues after the rename is a pointless extra pass. - **Safe-merge update.** `update` always performs GET → merge → PUT because some Jira deployments treat PUT as replace. Clearing a field (e.g. `unrelease`) emits an explicit `null` in 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.** `delete` without `--move-fix-to` / `--move-affected-to` leaves dangling `fixVersions` / `versions` arrays on issues. Prefer `--dry-run` first to read the reassign counts. - **Numeric IDs only on mutating subcommands.** `update`, `release`, `unrelease`, `archive`, `unarchive`, `move`, `merge`, `delete` validate that every positional and target version ID is numeric before any HTTP call, so values like `../../issue/KEY` cannot 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-by` use the paginated `/project/{key}/version` endpoint (Jira Cloud + DC ≥9.x). On older DC the endpoint returns 404 and the script automatically retries the flat endpoint, applying `--query` substring filter and `--order-by` sort 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.md 3.8 KB
# 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 ```bash # 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-123 ``` ### add ```bash # 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-123 ``` ### remove ```bash # 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-123 ``` ### Bulk patterns (no server-side bulk endpoint) ```bash # Watch every child of an epic uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-search.py --json query '"Epic Link" = PROJ-789' \ | jq -r '.[].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). -
worklog.md 5.2 KB
# 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, undo a worklog, or query worklogs across multiple issues by date range, user, project, epic or sprint. ## Before booking: is Jira the system of record? Check this once per team, before the first `add`. Where a separate time tracker syncs its entries into Jira, that tracker owns time and a direct `jira-worklog.py add` **double-books** — your entry plus the one the tracker syncs in later. The duplicate is easy to miss afterwards, because a synced entry is indistinguishable from a hand-written one on the issue. The team convention belongs in an `AGENTS.md` or a team runbook, not in this skill — it cannot know your setup. If Jira *is* the system of record, `add` is correct and the rest of this section applies. ## `jira-worklog.py add` — advanced flags ```bash # 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.py delete` — undo a booking Every `add` prints the new `Worklog ID`, and `list` shows the id of each entry — that id is the handle for `delete`. Use it to undo a booking made against the wrong issue, the wrong duration, or the wrong system (see above). ```bash # See which entries exist, with their ids uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-worklog.py list PROJ-123 # [2026-08-06] Jane Doe: 45m (id 409062) # Preview — shows the entry that would go away, deletes nothing uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-worklog.py delete PROJ-123 409062 --dry-run # Delete it uv run ${CLAUDE_SKILL_DIR}/scripts/core/jira-worklog.py delete PROJ-123 409062 ``` `delete` fetches the entry first and echoes its date, author and duration, so a mistyped id surfaces before anything is removed rather than after. Deleting your own worklog needs the "Delete Own Worklogs" permission; someone else's needs "Delete All Worklogs". ## `jira-worklog-query.py` — cross-cutting query ```bash # 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. ## By Tempo account (customer worked-time) To get the worked time booked to a **Tempo account** (a customer) for a month — across *all* workers, not just yourself — use `--tempo-account`: ```bash # Total worked time for a customer account in a month (all workers) uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-worklog-query.py \ --tempo-account ACME --from 2026-06-01 --to 2026-06-30 --detail ``` `--tempo-account` forces the Tempo backend and ignores `--user`/`--issue`/`--epic`/`--sprint`/`--project` (the account *is* the filter). Accepts a comma-separated list of account keys. > **Requires Tempo Timesheets on Jira Server/DC** — the whole Tempo backend (`--tempo-account`, `--backend tempo`, and `--backend auto`'s detection) talks to `/rest/tempo-timesheets/4`. Tempo **Cloud** exposes a different API (`api.tempo.io`) and is **not** supported: `--tempo-account`/`--backend tempo` fail with a clear message, and `--backend auto` falls back to the JQL backend. Why this exists: the plain worklog query (JQL or the Tempo `/worklogs` endpoint) can only filter by **worker**, issue, project or date — **not by Tempo account**. Time a customer books via a standby/support package is often logged by someone else on an issue you wouldn't guess, so a per-issue or per-user query silently returns nothing. Under the hood `--tempo-account` calls `POST /rest/tempo-timesheets/4/worklogs/search` with an `accountKey` array — the only endpoint that resolves a whole account. When a wrapper flag is missing, reach for the underlying REST before concluding the data is unreachable: ```bash set -a; source ~/.env.jira; set +a curl -sS -H "Authorization: Bearer $JIRA_PERSONAL_TOKEN" -H "Content-Type: application/json" \ -X POST "${JIRA_URL%/}/rest/tempo-timesheets/4/worklogs/search" \ -d '{"from":"2026-06-01","to":"2026-06-30","accountKey":["ACME"]}' # account lookup (key/name/lead): GET /rest/tempo-accounts/1/account/<id> ``` ## Relative dates `--from` and `--to` accept plain `YYYY-MM-DD`. For rolling queries, compute the dates in the shell: ```bash uv run ${CLAUDE_SKILL_DIR}/scripts/utility/jira-worklog-query.py \ --from "$(date -d 'monday last week' -I)" --to "$(date -I)" ```
-
-
scripts
-
core
-
jira-attachment.py 18.1 KB
#!/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() -
jira-issue.py 48.8 KB
#!/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 issue operations - get, update, and delete issue details.""" import json import sys from datetime import datetime, timedelta, timezone 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.changelog import ( classify_transition, compute_time_in_status, extract_status_transitions, extract_status_transitions_with_authors, find_transition_window, format_timedelta, parse_jira_datetime, ) from lib.client import LazyJiraClient, _sanitize_error, fetch_comments_paginated, resolve_assignee, resolve_status from lib.config import load_status_sets from lib.input import read_stdin_utf8 from lib.markup_cli import MarkupGates, guard_wiki_markup, markup_options from lib.output import compact_json, error, extract_adf_text, format_output, success, warning from lib.render import print_comment, print_description from lib.users import check_mentions_cli, person_label def _expand_label_args(raw: tuple[str, ...]) -> list[str]: """Split repeatable CLI args on commas and strip whitespace.""" out: list[str] = [] for entry in raw: if not entry: continue for part in entry.split(","): cleaned = part.strip() if cleaned: out.append(cleaned) return out def _labels_after_add_remove(existing: list[str], add: list[str], remove: list[str]) -> list[str]: """Merge labels case-insensitively while preserving first-seen casing from Jira.""" by_lower: dict[str, str] = {} for lab in existing: if not lab: continue low = lab.casefold() if low not in by_lower: by_lower[low] = lab for lab in add: low = lab.casefold() if low not in by_lower: by_lower[low] = lab remove_lower = {lab.casefold() for lab in remove} for low in remove_lower: by_lower.pop(low, None) return sorted(by_lower.values(), key=str.casefold) def _reference_label(ref) -> str: """Human-readable label for an issuetype/project reference dict.""" if not isinstance(ref, dict): return str(ref) return str(ref.get("name") or ref.get("key") or ref.get("id") or ref) def _reference_mismatch(requested, actual) -> tuple[str, str] | None: """Compare a requested issuetype/project reference against the re-fetched value. ``requested`` is whatever the caller put in the update payload — e.g. ``{"id": "7"}``, ``{"name": "Sub: Bug"}`` or ``{"key": "ABC"}``. ``actual`` is the field as returned by a fresh ``client.issue(...)`` read. Returns ``None`` when the change is verified (or cannot be meaningfully checked), or an ``(requested_label, actual_label)`` tuple on mismatch. Why: Jira's ``PUT /issue/{key}`` silently ignores some issuetype/project changes on Server/DC, returning success while leaving the field untouched (#115). We compare on whichever identifier the caller supplied (id / key / name) so the check works regardless of how the reference was expressed. """ if not isinstance(requested, dict) or not isinstance(actual, dict): return None # unrecognized shape — don't raise a false alarm for attr in ("id", "key", "name"): if attr not in requested: continue got = actual.get(attr) if got is None: continue # this identifier isn't exposed in the refreshed value want = str(requested[attr]) got = str(got) # Jira canonicalizes project keys to uppercase and resolves issue-type # names case-insensitively, so a caller-supplied lowercase value can be # applied correctly yet come back in different casing. Compare key/name # case-insensitively; only the opaque numeric id is matched exactly. applied = got == want if attr == "id" else got.casefold() == want.casefold() if not applied: return (_reference_label(requested), _reference_label(actual)) return None # matched on the supplied identifier — change applied return None # nothing comparable # ═══════════════════════════════════════════════════════════════════════════════ # 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 issue operations. Get, update, and delete Jira issue details. """ 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) # Kept for callers that resolve the config themselves rather than through # the client - the render preview does. Without them guard_wiki_markup # reads None and previews against the DEFAULT profile, which is a # different tenant from the one this command is writing to. ctx.obj["env_file"] = env_file ctx.obj["profile"] = profile @cli.command() @click.argument("issue_key") @click.option("--fields", "-f", help="Comma-separated fields to return") @click.option("--expand", "-e", help="Fields to expand (changelog,transitions,renderedFields)") @click.option("--truncate", type=int, metavar="N", help="Truncate description to N characters") @click.option("--full", is_flag=True, help="[DEPRECATED] Show full content (now default behavior)") @click.option( "--raw", is_flag=True, help=( "With --json: preserve every key on the Jira response (including null " "customfields). Without --raw, null/empty fields are stripped. In either " "case an extra `webLinks` key is added by this script from a separate " "remote-links API call." ), ) @click.pass_context def get( ctx, issue_key: str, fields: str | None, expand: str | None, truncate: int | None, full: bool, raw: bool, ): """Get issue details. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) Examples: jira-issue get PROJ-123 jira-issue get PROJ-123 --fields summary,status,assignee jira-issue get PROJ-123 --expand changelog,transitions jira-issue --json get PROJ-123 # compact JSON (null/empty stripped) jira-issue --json get PROJ-123 --raw # full Jira payload, incl. null customfields """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] # Warn about deprecated --full flag if full: warning("--full is deprecated (full content is now shown by default). Use --truncate N to limit output.") try: # Normalize requested fields once — used for both fetch gating and display parsed = [f.strip() for f in fields.split(",") if f.strip()] if fields else [] requested = set(parsed) if parsed else None # Build parameters — strip our pseudo-field "weblinks" before sending to Jira params = {} if parsed: api_fields = ",".join(f for f in parsed if f != "weblinks") if api_fields: params["fields"] = api_fields if expand: params["expand"] = expand issue = client.issue(issue_key, **params) # Fetch web links (separate API call, not a field on the issue) # Skip if --quiet or if --fields was given without "weblinks" web_links = [] if not ctx.obj["quiet"] and (requested is None or "weblinks" in requested): try: web_links = client.get_issue_remote_links(issue_key) except Exception: if ctx.obj["debug"]: raise warning("Failed to fetch web links") web_links = [] if ctx.obj["json"]: issue["webLinks"] = web_links payload = issue if raw else compact_json(issue) format_output(payload, as_json=True) elif ctx.obj["quiet"]: print(issue["key"]) else: _print_issue(issue, truncate=truncate, requested_fields=requested, web_links=web_links) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get issue {issue_key}: {e}") sys.exit(1) def _print_issue( issue: dict, truncate: int | None = None, requested_fields: set | None = None, web_links: list | None = None, ) -> None: """Pretty print issue details. Args: issue: The issue dict from Jira API truncate: If set, truncate description to this many characters requested_fields: Pre-parsed set of field names to display (None = show all) web_links: List of remote link dicts from a separate API call """ fields = issue.get("fields", {}) # Accept both set and comma-separated string for backwards compatibility if isinstance(requested_fields, str): requested = set(f.strip() for f in requested_fields.split(",")) else: requested = requested_fields def should_show(field_name: str) -> bool: """Check if a field should be shown based on requested fields.""" if requested is None: return True return field_name in requested def field_available(field_name: str) -> bool: """Check if a field was returned by the API.""" return field_name in fields # Header with summary if should_show("summary") or requested is None: summary = fields.get("summary", "No summary") if field_available("summary") else "[not requested]" print(f"\n{issue['key']}: {summary}") print("=" * 60) else: print(f"\n{issue['key']}") print("=" * 60) # Status, type, priority row - only show if any were requested or no filter show_status_row = requested is None or any(f in requested for f in ["status", "issuetype", "priority"]) if show_status_row: parts = [] if should_show("issuetype") and field_available("issuetype"): issue_type = fields.get("issuetype", {}).get("name", "Unknown") parts.append(f"Type: {issue_type}") if should_show("status") and field_available("status"): status = fields.get("status", {}).get("name", "Unknown") parts.append(f"Status: {status}") if should_show("priority") and field_available("priority"): priority = fields.get("priority", {}).get("name", "None") if fields.get("priority") else "None" parts.append(f"Priority: {priority}") if parts: print(" | ".join(parts)) # Assignee and reporter row show_people_row = requested is None or any(f in requested for f in ["assignee", "reporter"]) if show_people_row: parts = [] if should_show("assignee") and field_available("assignee"): parts.append(f"Assignee: {person_label(fields.get('assignee'), fallback='Unassigned')}") if should_show("reporter") and field_available("reporter"): parts.append(f"Reporter: {person_label(fields.get('reporter'))}") if parts: print(" | ".join(parts)) # Labels if should_show("labels") and field_available("labels"): labels = fields.get("labels", []) if labels: print(f"Labels: {', '.join(labels)}") # Description if should_show("description") and field_available("description"): description = fields.get("description") if description: print("\nDescription:") # Handle both string and ADF format if isinstance(description, str): desc_text = description elif isinstance(description, dict): # ADF format - extract text content desc_text = extract_adf_text(description) else: desc_text = str(description) # Truncate if requested if truncate and len(desc_text) > truncate: # Find word boundary for clean truncation truncated = desc_text[:truncate].rsplit(" ", 1)[0] print(f" {truncated}...") print(f" [truncated at {truncate} chars]") else: # Print full description, preserving line breaks for line in desc_text.split("\n"): print(f" {line}") # Dates show_dates_row = requested is None or any(f in requested for f in ["created", "updated"]) if show_dates_row: parts = [] if should_show("created") and field_available("created"): created = fields.get("created", "")[:10] if fields.get("created") else "N/A" parts.append(f"Created: {created}") if should_show("updated") and field_available("updated"): updated = fields.get("updated", "")[:10] if fields.get("updated") else "N/A" parts.append(f"Updated: {updated}") if parts: print(f"\n{' | '.join(parts)}") # Attachments if should_show("attachment") and field_available("attachment"): attachments = fields.get("attachment", []) if attachments: print("\n" + "=" * 60) print("ATTACHMENTS") print("=" * 60) for att in attachments: filename = att.get("filename", "Unknown") url = att.get("content", "") print(f" • {filename} - {url}") # Issue Links if should_show("issuelinks") and field_available("issuelinks"): issue_links = fields.get("issuelinks", []) if issue_links: print("\n" + "=" * 60) print("ISSUE LINKS") print("=" * 60) for link in issue_links: link_type = link.get("type", {}) if "outwardIssue" in link: outward = link["outwardIssue"] label = link_type.get("outward", "links to") key = outward.get("key", "?") summary = outward.get("fields", {}).get("summary", "") print(f" {label} \u2192 {key}: {summary}") if "inwardIssue" in link: inward = link["inwardIssue"] label = link_type.get("inward", "is linked by") key = inward.get("key", "?") summary = inward.get("fields", {}).get("summary", "") print(f" {label} \u2190 {key}: {summary}") # Web Links (from separate API call, gated by --fields like issue links) if web_links and should_show("weblinks"): print("\n" + "=" * 60) print("WEB LINKS") print("=" * 60) for link in web_links: link_id = link.get("id", "?") obj = link.get("object", {}) title = obj.get("title", "(untitled)") link_url = obj.get("url", "") print(f" [{link_id}] {title} \u2014 {link_url}") # Comments \u2014 always surface the count when the comment field is present, so a # populated discussion is never invisible (the original silent `-f comment` trap). # Full bodies stay in the `work` command / `jira-comment.py list`. if field_available("comment"): comment_field = fields.get("comment") or {} # Jira may send total/comments as explicit null, so guard against None # rather than relying on dict.get defaults (a present-but-null key skips them). comment_total = comment_field.get("total") if comment_total is None: comment_total = len(comment_field.get("comments") or []) if comment_total: print( f"\nComments: {comment_total} " f"(run `jira-issue.py work {issue['key']}` or `jira-comment.py list {issue['key']}` to read them)" ) else: print("\nComments: 0") # Parent \u2014 cheap, high-value metadata; same silent-omission gap as comments. if field_available("parent"): parent = fields.get("parent") or {} parent_key = parent.get("key") if parent_key: parent_summary = (parent.get("fields") or {}).get("summary", "") print(f"\nParent: {parent_key}" + (f": {parent_summary}" if parent_summary else "")) # Subtasks \u2014 list compactly (keys are short); closes the silent `-f subtasks` gap. if field_available("subtasks"): subtasks = fields.get("subtasks") or [] if subtasks: print(f"\nSubtasks ({len(subtasks)}):") for subtask in subtasks: subtask_fields = subtask.get("fields") or {} subtask_status = (subtask_fields.get("status") or {}).get("name", "") suffix = f" [{subtask_status}]" if subtask_status else "" print(f" \u2022 {subtask.get('key', '?')}: {subtask_fields.get('summary') or ''}{suffix}") # Safety net: never let an explicitly requested field render nothing silently. # Any -f field that reached the payload but has no renderer above gets a # one-line pointer instead of vanishing (the core correctness fix). if requested is not None: rendered_fields = { "summary", "issuetype", "status", "priority", "assignee", "reporter", "labels", "description", "created", "updated", "attachment", "issuelinks", "weblinks", "comment", "parent", "subtasks", } for field_name in sorted(requested): if field_name not in rendered_fields and field_available(field_name): print(f"\n{field_name}: present in the response but not rendered here, use `--json` to view it") print() @cli.command("time-in-status") @click.argument("issue_key") @click.option( "--status", "-s", "status_filter", help="Show only time spent in this status (resolved via resolve_status)", ) @click.pass_context def time_in_status_cmd(ctx, issue_key: str, status_filter: str | None): """Show how long an issue has spent in each status. Fetches the changelog and computes cumulative duration per status. If the issue has re-entered a status, durations are summed. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) Examples: jira-issue time-in-status PROJ-123 jira-issue time-in-status PROJ-123 --status Review jira-issue --json time-in-status PROJ-123 """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: issue = client.issue(issue_key, expand="changelog") fields = issue.get("fields", {}) current_status = (fields.get("status") or {}).get("name", "") created_raw = fields.get("created", "") if not created_raw: error(f"Issue {issue_key} has no 'created' timestamp") sys.exit(1) transitions = extract_status_transitions(issue) issue_created = parse_jira_datetime(created_raw) now = datetime.now(timezone.utc) per_status = compute_time_in_status(issue_created, transitions, current_status, now) # Optionally filter to a single status (with resolution) resolved_status = None if status_filter: try: resolved_status = resolve_status(client, status_filter) except ValueError as e: error(str(e)) sys.exit(1) if ctx.obj["json"]: # Prefer the canonical key from the fetched issue over the user-supplied # identifier (callers may pass an issue ID; Jira returns the key). payload = { "key": issue.get("key", issue_key), "current_status": current_status, "time_in_status": {name: int(delta.total_seconds()) for name, delta in per_status.items()}, } if resolved_status is not None: payload["filter_status"] = resolved_status payload["filter_seconds"] = int(per_status.get(resolved_status, timedelta(0)).total_seconds()) format_output(payload, as_json=True) return if ctx.obj["quiet"]: if resolved_status is not None: delta = per_status.get(resolved_status) print(format_timedelta(delta) if delta else "0m") else: print(current_status) return summary = fields.get("summary", "") print(f"\n{issue_key}: {summary}") print("=" * 60) if resolved_status is not None: delta = per_status.get(resolved_status) duration = format_timedelta(delta) if delta else "0m" marker = " (current)" if resolved_status == current_status else "" print(f'In "{resolved_status}"{marker}: {duration}') print() return # Show all statuses, ordered by first appearance in the timeline order = _status_order(current_status, transitions) width = max((len(s) for s in per_status), default=0) print("Time in status:") for name in order: if name not in per_status: continue marker = " ← current" if name == current_status else "" print(f" {name.ljust(width)} {format_timedelta(per_status[name])}{marker}") print() except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to compute time-in-status for {issue_key}: {e}") sys.exit(1) def _status_order(current_status: str, transitions: list) -> list[str]: """Return statuses in the order they first appear on the timeline.""" seen: list[str] = [] if transitions: first_from = transitions[0].get("from") or current_status if first_from and first_from not in seen: seen.append(first_from) for t in transitions: to = t.get("to") if to and to not in seen: seen.append(to) if current_status and current_status not in seen: seen.append(current_status) return seen @cli.command() @click.argument("issue_key") @click.option("--summary", "-s", help="New summary") @click.option("--description", "-d", help="New description (Jira wiki markup; '-' reads from stdin)") @click.option("--priority", "-p", help="Priority name") @click.option("--labels", "-l", help="Comma-separated labels (replaces existing)") @click.option( "--add-label", multiple=True, help="Add label(s); repeatable and comma-separated (case-insensitive dedupe)", ) @click.option( "--remove-label", multiple=True, help="Remove label(s); repeatable and comma-separated (matches case-insensitively)", ) @click.option("--assignee", "-a", help="Assignee username or email") @click.option("--fields-json", help="JSON string of additional fields to update") @click.option("--no-verify-mentions", is_flag=True, help="Skip [~username] mention verification in --description") @markup_options @click.option("--dry-run", is_flag=True, help="Show what would be updated without making changes") @click.pass_context def update( ctx, issue_key: str, summary: str | None, description: str | None, priority: str | None, labels: str | None, add_label: tuple[str, ...], remove_label: tuple[str, ...], assignee: str | None, fields_json: str | None, no_verify_mentions: bool, gates: MarkupGates, dry_run: bool, ): """Update issue fields. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) Examples: jira-issue update PROJ-123 --summary "New title" jira-issue update PROJ-123 --description "$(cat body.txt)" jira-issue update PROJ-123 --description - # read from stdin jira-issue update PROJ-123 --priority High --labels backend,urgent jira-issue update PROJ-123 --fields-json '{"customfield_10001": "value"}' jira-issue update PROJ-123 --summary "Test" --dry-run """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] # Build update payload update_fields = {} if summary: update_fields["summary"] = summary if description is not None: if description == "-": if sys.stdin.isatty(): error( "'-' requires piped input but stdin is a terminal", suggestion="Usage: cat body.txt | jira-issue update PROJ-123 --description -", ) sys.exit(1) max_size = 256 * 1024 # 256KB, above Jira's description limit try: description = read_stdin_utf8(max_size + 1) except UnicodeDecodeError: error( "stdin contains invalid text encoding (expected UTF-8)", suggestion="Ensure the piped file is valid UTF-8 text, not binary data.", ) sys.exit(1) if len(description) > max_size: error( f"description from stdin exceeds {max_size} bytes", suggestion="Truncate the input or split the description across multiple updates.", ) sys.exit(1) description = description.rstrip("\n") # A description renders wiki markup — same gates as jira-comment add description = guard_wiki_markup( description, gates=gates.offline() if dry_run else gates, issue_key=issue_key, env_file=ctx.obj.get("env_file"), profile=ctx.obj.get("profile"), label="description", ) check_mentions_cli(client, description, skip=no_verify_mentions) update_fields["description"] = description if priority: update_fields["priority"] = {"name": priority} if labels and (add_label or remove_label): error("Do not combine --labels with --add-label/--remove-label (choose replace or incremental update).") sys.exit(1) if labels: update_fields["labels"] = [l.strip() for l in labels.split(",")] if add_label or remove_label: issue = client.issue(issue_key, fields="labels") existing = (issue.get("fields") or {}).get("labels") or [] add_clean = _expand_label_args(add_label) remove_clean = _expand_label_args(remove_label) update_fields["labels"] = _labels_after_add_remove(list(existing), add_clean, remove_clean) if assignee: update_fields["assignee"] = resolve_assignee(client, assignee) if fields_json: try: extra_fields = json.loads(fields_json) update_fields.update(extra_fields) except json.JSONDecodeError as e: error(f"Invalid JSON in --fields-json: {e}") sys.exit(1) if not update_fields: error("No fields specified for update") click.echo( "\nUse one or more of: --summary, --description, --priority, --labels, " "--add-label, --remove-label, --assignee, --fields-json" ) sys.exit(1) if dry_run: warning("DRY RUN - No changes will be made") print(f"\nWould update {issue_key} with:") for key, value in update_fields.items(): print(f" {key}: {value}") return try: client.update_issue_field(issue_key, update_fields) # Read-after-write verification (#115). update_issue_field returns # without error even when Jira's PUT /issue/{key} endpoint silently # ignores the change — which it does for issuetype and project on # Server/DC. Re-fetch those fields and compare against what we asked # for, mirroring the defensive check in jira-move.py, so we never # report a false-positive success. verify_fields = [f for f in ("issuetype", "project") if f in update_fields] if verify_fields: refreshed = client.issue(issue_key, fields=",".join(verify_fields)) refreshed_fields = refreshed.get("fields") or {} for field in verify_fields: mismatch = _reference_mismatch(update_fields[field], refreshed_fields.get(field)) if not mismatch: continue requested_label, actual_label = mismatch if field == "issuetype": error( f"issuetype change was not applied: issue is still '{actual_label}' " f"(requested '{requested_label}')", suggestion=( "Jira's edit endpoint silently ignores some issue-type changes " "(notably between Sub-Task types). Use 'jira-move issue' for type " "changes, or change it via the Jira UI's Move action." ), ) else: error( f"project change was not applied: issue is still in '{actual_label}' " f"(requested '{requested_label}')", suggestion=( "Moving an issue between projects is not supported via the edit " "endpoint. Use the Jira UI's Move action." ), ) sys.exit(1) if ctx.obj["quiet"]: print(issue_key) elif ctx.obj["json"]: format_output({"key": issue_key, "updated": list(update_fields.keys())}, as_json=True) else: success(f"Updated {issue_key}") for key in update_fields: print(f" ✓ {key}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to update {issue_key}: {e}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.option("--delete-subtasks", is_flag=True, help="Also delete subtasks of the issue") @click.option("--dry-run", is_flag=True, help="Show what would be deleted without making changes") @click.pass_context def delete(ctx, issue_key: str, delete_subtasks: bool, dry_run: bool): """Delete an issue. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) Requires delete permission in the Jira project. Use --dry-run to preview. Examples: jira-issue delete PROJ-123 jira-issue delete PROJ-123 --dry-run jira-issue delete PROJ-123 --delete-subtasks """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: # Fetch issue summary for confirmation output issue = client.issue(issue_key, fields="summary,subtasks") summary = issue.get("fields", {}).get("summary", "No summary") subtasks = issue.get("fields", {}).get("subtasks", []) if dry_run: warning("DRY RUN - No issue will be deleted") print(f"\nWould delete {issue_key}: {summary}") if subtasks: print(f"\n Subtasks ({len(subtasks)}):") for st in subtasks: st_summary = st.get("fields", {}).get("summary", "No summary") print(f" {st['key']}: {st_summary}") if not delete_subtasks: warning("Subtasks exist. Use --delete-subtasks to delete them too, or deletion will fail.") return client.delete_issue(issue_key, delete_subtasks=delete_subtasks) if ctx.obj["quiet"]: print("ok") elif ctx.obj["json"]: format_output({"key": issue_key, "deleted": True, "subtasks_deleted": delete_subtasks}, as_json=True) else: success(f"Deleted {issue_key}: {summary}") if subtasks and delete_subtasks: print(f" Also deleted {len(subtasks)} subtask(s)") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to delete {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) # ═══════════════════════════════════════════════════════════════════════════════ # Intent verbs: work / qa / qa-fail / act # # Each verb is a single-call composition that returns the *minimal* bundle a # user actually needs for one specific intent — instead of forcing them to # stitch together `get` + `comment list` + `transition list` themselves. # ═══════════════════════════════════════════════════════════════════════════════ # Note: ``comment`` is intentionally NOT in INTENT_FIELDS — Jira returns only # the first 50 comments in the embedded payload. The intent verbs fetch # comments separately via fetch_comments_paginated() to avoid silently # truncating long-running tickets. INTENT_FIELDS = ( "summary,status,assignee,reporter,priority,issuetype,description,attachment,issuelinks,labels,created,updated" ) def _author_matches(author: dict, key: str, name: str) -> bool: """True if a comment/transition author matches by Server name or Cloud accountId.""" if not author: return False a_key = author.get("name") or author.get("accountId") or "" a_name = author.get("displayName", "") return bool((key and a_key == key) or (name and a_name == name)) def _comments_in_range(comments: list, start, end, author_filter: tuple[str, str] | None = None) -> list: """Filter comments by ``start <= created < end`` (half-open), optionally by author key+name. The end bound is exclusive so a comment created exactly at the next status transition is attributed to the next QA window, not the current one. """ out = [] for c in comments: try: created = parse_jira_datetime(c.get("created", "")) except (ValueError, TypeError): continue if start is not None and created < start: continue if end is not None and created >= end: continue if author_filter is not None: if not _author_matches(c.get("author") or {}, *author_filter): continue out.append(c) return out def _dedupe_comments(comments: list) -> list: """Deduplicate by comment ID, returning chronological order.""" seen: set[str] = set() out: list = [] for c in comments: cid = c.get("id", "") if cid in seen: continue seen.add(cid) out.append(c) return sorted(out, key=lambda c: c.get("created", "")) def _collect_handover_bundle(issue: dict, comments: list, status_sets: dict) -> dict: """Compose the qa (handover) bundle. See PLAN-context-fetch-optimization.md.""" transitions = extract_status_transitions_with_authors(issue) into_qa_indices = [i for i, t in enumerate(transitions) if classify_transition(t, status_sets) == "into_qa"] if not into_qa_indices: return {"fallback": True, "comments": comments[-5:], "transition": None} target_idx = into_qa_indices[-1] target = transitions[target_idx] t_transition = target["created"] t_prev, t_next = find_transition_window(transitions, target_idx) one_hour = timedelta(hours=1) end_for_author = (t_transition + one_hour) if t_next is None else min(t_next, t_transition + one_hour) handover = _comments_in_range( comments, t_prev, end_for_author, author_filter=(target["author_key"], target["author_name"]) ) after = _comments_in_range(comments, t_transition, t_next) return { "fallback": False, "comments": _dedupe_comments(handover + after), "transition": target, } def _collect_reject_bundle(issue: dict, comments: list, status_sets: dict) -> dict: """Compose the qa-fail (reject) bundle. See PLAN-context-fetch-optimization.md.""" transitions = extract_status_transitions_with_authors(issue) reject_indices = [i for i, t in enumerate(transitions) if classify_transition(t, status_sets) == "reject"] if not reject_indices: return {"fallback": True, "comments": comments[-5:], "transition": None} target_idx = reject_indices[-1] target = transitions[target_idx] t_transition = target["created"] _, t_next = find_transition_window(transitions, target_idx) # Most recent INTO_QA before the reject = QA window start + implementer identity. implementer = None t_prev_into_qa = None for i in range(target_idx - 1, -1, -1): if classify_transition(transitions[i], status_sets) == "into_qa": t_prev_into_qa = transitions[i]["created"] implementer = (transitions[i]["author_key"], transitions[i]["author_name"]) break one_hour = timedelta(hours=1) reviewer_filter = (target["author_key"], target["author_name"]) reviewer_comments = _comments_in_range( comments, t_prev_into_qa, t_transition + one_hour, author_filter=reviewer_filter ) after = _comments_in_range(comments, t_transition, t_next) impl_comments = [] if implementer is not None: # Extend back -1h to catch handover comments written just before the # INTO_QA click (empirically 80% of handover comments precede the click). impl_start = (t_prev_into_qa - one_hour) if t_prev_into_qa else None impl_comments = _comments_in_range(comments, impl_start, t_transition, author_filter=implementer) return { "fallback": False, "comments": _dedupe_comments(reviewer_comments + after + impl_comments), "transition": target, "implementer": implementer, } def _print_intent_header(issue: dict) -> None: fields = issue.get("fields", {}) summary = fields.get("summary", "") status = (fields.get("status") or {}).get("name", "") assignee = person_label(fields.get("assignee"), fallback="Unassigned") print(f"\n{issue['key']}: {summary}") print("=" * 60) print(f"Status: {status} | Assignee: {assignee}") def _intent_bundle_payload(issue: dict, comments: list, *, extras: dict | None = None) -> dict: """Build the JSON payload for an intent verb.""" fields = issue.get("fields", {}) payload = { "key": issue.get("key"), "summary": fields.get("summary"), "status": (fields.get("status") or {}).get("name"), "assignee": (fields.get("assignee") or {}).get("displayName"), "description": fields.get("description"), "comments": comments, } if extras: payload.update(extras) return payload def _resolve_status_sets_for_ctx(ctx, issue_key: str | None = None) -> dict: """Load status sets, mirroring load_config's auto-resolution. Honors --profile if explicit; otherwise auto-resolves by issue-key project prefix or default profile. Falls back to env / built-in defaults if no profiles.json is configured. """ profile = None client_obj = ctx.obj.get("client") if client_obj is not None: profile = getattr(client_obj, "_profile", None) return load_status_sets(profile=profile, issue_key=issue_key) @cli.command() @click.argument("issue_key") @click.option("--truncate", type=int, metavar="N", help="Truncate description and each comment to N chars") @click.pass_context def work(ctx, issue_key: str, truncate: int | None): """Fetch full working context: description + all comments + attachments + links. Use when starting work on a ticket or doing triage. Single call. Example: jira-issue work NRS-4412 """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: issue = client.issue(issue_key, fields=INTENT_FIELDS) # --quiet only validates the fetch — skip optional side calls (mirrors `get --quiet`) if ctx.obj["quiet"]: print(issue["key"]) return comments, _ = fetch_comments_paginated(client, issue_key) web_links = [] try: web_links = client.get_issue_remote_links(issue_key) except Exception: if ctx.obj["debug"]: raise warning("Failed to fetch web links") if ctx.obj["json"]: fields = issue.get("fields", {}) extras = { "attachments": fields.get("attachment") or [], "issueLinks": fields.get("issuelinks") or [], "webLinks": web_links, } format_output(_intent_bundle_payload(issue, comments, extras=extras), as_json=True) return issue["webLinks"] = web_links _print_issue(issue, truncate=truncate, web_links=web_links) if comments: print("=" * 60) print(f"COMMENTS ({len(comments)} total — chronological)") print("=" * 60) for c in comments: print_comment(c, truncate=truncate) print() except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to fetch work context for {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.option("--truncate", type=int, metavar="N", help="Truncate description and each comment to N chars") @click.pass_context def qa(ctx, issue_key: str, truncate: int | None): """Fetch QA review context: description + handover comments since transition into QA. Use when starting a QA review. Returns the implementer's handover comment (regardless of whether written before or after the transition click) plus any subsequent QA discussion. Example: jira-issue qa NRS-4412 """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: issue = client.issue(issue_key, fields=INTENT_FIELDS, expand="changelog") if ctx.obj["quiet"]: print(issue["key"]) return comments, _ = fetch_comments_paginated(client, issue_key) sets = _resolve_status_sets_for_ctx(ctx, issue_key) bundle = _collect_handover_bundle(issue, comments, sets) if ctx.obj["json"]: extras = {"handover_fallback": bundle["fallback"]} if bundle["transition"]: extras["handover_transition"] = { "created": bundle["transition"]["created"].isoformat(), "from": bundle["transition"]["from"], "to": bundle["transition"]["to"], "author": bundle["transition"]["author_name"], } format_output(_intent_bundle_payload(issue, bundle["comments"], extras=extras), as_json=True) return _print_intent_header(issue) print_description(issue, truncate=truncate) if bundle["fallback"]: print("\n[no INTO-QA transition found — falling back to last 5 comments]") else: t = bundle["transition"] print(f"\nHandover: {t['created'].isoformat()} by {t['author_name']} ({t['from']} → {t['to']})") print("\n" + "=" * 60) print(f"HANDOVER COMMENTS ({len(bundle['comments'])})") print("=" * 60) for c in bundle["comments"]: print_comment(c, truncate=truncate) print() except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to fetch QA context for {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) @cli.command("qa-fail") @click.argument("issue_key") @click.option("--truncate", type=int, metavar="N", help="Truncate description and each comment to N chars") @click.pass_context def qa_fail(ctx, issue_key: str, truncate: int | None): """Fetch QA-fail follow-up context: description + reviewer rejection + implementer scope. Use when continuing work after a QA reject. Returns the reviewer's rejection comment (regardless of order vs. transition), the implementer's scope/clarification comments from the same QA window, and any subsequent discussion. Example: jira-issue qa-fail NRS-4412 """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: issue = client.issue(issue_key, fields=INTENT_FIELDS, expand="changelog") if ctx.obj["quiet"]: print(issue["key"]) return comments, _ = fetch_comments_paginated(client, issue_key) sets = _resolve_status_sets_for_ctx(ctx, issue_key) bundle = _collect_reject_bundle(issue, comments, sets) if ctx.obj["json"]: extras = {"reject_fallback": bundle["fallback"]} if bundle["transition"]: extras["reject_transition"] = { "created": bundle["transition"]["created"].isoformat(), "from": bundle["transition"]["from"], "to": bundle["transition"]["to"], "reviewer": bundle["transition"]["author_name"], } if bundle.get("implementer"): extras["implementer"] = bundle["implementer"][1] format_output(_intent_bundle_payload(issue, bundle["comments"], extras=extras), as_json=True) return _print_intent_header(issue) print_description(issue, truncate=truncate) if bundle["fallback"]: print("\n[no REJECT transition found — falling back to last 5 comments]") else: t = bundle["transition"] print(f"\nReject: {t['created'].isoformat()} by {t['author_name']} ({t['from']} → {t['to']})") if bundle.get("implementer"): print(f"Implementer (for scope context): {bundle['implementer'][1]}") print("\n" + "=" * 60) print(f"QA-FAIL COMMENTS ({len(bundle['comments'])})") print("=" * 60) for c in bundle["comments"]: print_comment(c, truncate=truncate) print() except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to fetch QA-fail context for {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.pass_context def act(ctx, issue_key: str): """Fetch meta + available transitions in one call (use before changing status). Example: jira-issue act NRS-4412 jira-issue --json act NRS-4412 | jq '.transitions[].name' """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: issue = client.issue(issue_key, fields="summary,status,assignee,priority,issuetype") # Fetching transitions IS the core purpose of `act` — surface failures # rather than silently returning an empty list (which a caller might # interpret as "no transitions are available"). transitions = client.get_issue_transitions(issue_key) or [] if ctx.obj["json"]: payload = { "key": issue.get("key"), "summary": issue.get("fields", {}).get("summary"), "status": (issue.get("fields", {}).get("status") or {}).get("name"), "assignee": (issue.get("fields", {}).get("assignee") or {}).get("displayName"), "transitions": [ {"id": t.get("id"), "name": t.get("name") or t.get("to", {}).get("name")} for t in transitions ], } format_output(payload, as_json=True) return if ctx.obj["quiet"]: print(issue["key"]) return _print_intent_header(issue) print("\nAvailable transitions:") if not transitions: print(" (none)") for t in transitions: name = t.get("name") or (t.get("to") or {}).get("name", "?") print(f" • {name} (id={t.get('id', '?')})") print() except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to fetch act context for {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) if __name__ == "__main__": cli() -
jira-search.py 9.5 KB
#!/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() -
jira-setup.py 19.4 KB
#!/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", # ] # /// """Interactive Jira credential setup - configure authentication interactively.""" import os 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 import click import requests from atlassian import Jira from lib.client import ( JIRA_TIMEOUT, AuthenticationError, SessionExpiredError, _patch_session_for_response_validation, _sanitize_error, ) from lib.config import DEFAULT_ENV_FILE, PROFILES_FILE, is_cloud_url, load_env from lib.output import error, success, warning # ═══════════════════════════════════════════════════════════════════════════════ # Exit Codes # ═══════════════════════════════════════════════════════════════════════════════ EXIT_SUCCESS = 0 EXIT_USER_ABORT = 1 EXIT_VALIDATION_FAILED = 2 def detect_jira_type(url: str) -> str: """Detect if URL is Jira Cloud or Server/Data Center. Args: url: Jira instance URL Returns: 'cloud' for Atlassian Cloud, 'server' for Server/DC """ return "cloud" if is_cloud_url(url) else "server" def validate_url(url: str) -> tuple[bool, str]: """Validate Jira URL is reachable. Args: url: URL to validate Returns: Tuple of (success, message) """ if not url.startswith(("http://", "https://")): return False, "URL must start with http:// or https://" try: response = requests.head(url, timeout=10, allow_redirects=True) status = response.status_code # 405 = HEAD not allowed — fall back to GET if status == 405: response = requests.get(url, timeout=10, allow_redirects=True, stream=True) response.close() status = response.status_code if status < 400: return True, f"Server reachable (status: {status})" if status in (401, 403): return True, f"Server reachable, authentication required (status: {status})" if status < 500: return False, f"Client error when contacting server (status: {status})" return False, f"Server error (status: {status})" except requests.exceptions.Timeout: return False, "Connection timeout - server did not respond" except requests.exceptions.ConnectionError as e: return False, f"Connection failed: {_sanitize_error(str(e))}" def validate_credentials(url: str, auth_type: str, **kwargs) -> tuple[bool, str]: """Validate Jira credentials by attempting authentication. Args: url: Jira instance URL auth_type: 'cloud' or 'server' **kwargs: Authentication credentials Returns: Tuple of (success, message/user_info) """ try: if auth_type == "cloud": client = Jira( url=url, username=kwargs["username"], password=kwargs["api_token"], cloud=True, timeout=JIRA_TIMEOUT, ) else: client = Jira( url=url, token=kwargs["personal_token"], timeout=JIRA_TIMEOUT, ) _patch_session_for_response_validation(client, url) user = client.myself() if isinstance(user, dict): display_name = user.get("displayName", user.get("name", "Unknown")) email = user.get("emailAddress", "") else: user_str = str(user) if user else "" # Defensive fallback for mocked/unpatched clients where an HTML # 2FA/Secure Login page reaches this point instead of raising # SessionExpiredError from the response-validation hook. if user_str.lstrip().startswith(("<!DOCTYPE", "<html", "<HTML")): return False, ( "Two-factor authentication (2FA/Secure Login) intercepted the API call " "or the session expired. Your PAT may not bypass 2FA on this instance. " "Check Jira admin settings or create a new PAT with API access." ) display_name = user_str or "Unknown" email = "" return True, f"{display_name}" + (f" ({email})" if email else "") except SessionExpiredError: return False, ( "Two-factor authentication (2FA/Secure Login) intercepted the API call " "or the session expired. Your PAT may not bypass 2FA on this instance. " "Check Jira admin settings or create a new PAT with API access." ) except AuthenticationError: return False, "Authentication failed - invalid credentials or insufficient permissions" except Exception as e: return False, f"Connection error: {_sanitize_error(str(e))}" def write_env_file(path: Path, config: dict) -> None: """Write configuration to environment file. Security note: Credentials are stored in clear text, similar to standard credential files like ~/.netrc, ~/.npmrc, or ~/.aws/credentials. The file is protected by restrictive filesystem permissions (0600 - owner read/write only). This is an intentional design choice following common CLI tool patterns. Args: path: Path to write config: Configuration dictionary """ lines = [ "# Jira CLI Configuration", "# Generated by jira-setup.py", "# Security: This file contains credentials and is protected by 0600 permissions", "", ] for key, value in config.items(): if value: lines.append(f"{key}={value}") lines.append("") fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as f: f.write("\n".join(lines)) os.chmod(path, 0o600) def write_profile(profile_name: str, profile_data: dict) -> None: """Write or update a profile in ~/.jira/profiles.json. Args: profile_name: Name for the profile profile_data: Profile configuration dict (url, auth, token/credentials, projects) """ profiles_dir = PROFILES_FILE.parent # Load existing or create new if PROFILES_FILE.exists(): try: data = json.loads(PROFILES_FILE.read_text()) except json.JSONDecodeError: warning(f"{PROFILES_FILE} is corrupted. Creating backup and starting fresh.") PROFILES_FILE.replace(PROFILES_FILE.with_suffix(".json.bak")) data = {"version": 1, "profiles": {}} else: # Validate structure: must be a dict with a 'profiles' dict if not isinstance(data, dict) or not isinstance(data.get("profiles"), dict): warning(f"{PROFILES_FILE} has invalid structure. Creating backup and starting fresh.") PROFILES_FILE.replace(PROFILES_FILE.with_suffix(".json.bak")) data = {"version": 1, "profiles": {}} else: profiles_dir.mkdir(parents=True, exist_ok=True) data = {"version": 1, "profiles": {}} # Update profile data["profiles"][profile_name] = profile_data # Set default if first profile if "default" not in data or not data["default"]: data["default"] = profile_name # Write with restricted permissions from creation (no race condition) fd = os.open(PROFILES_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) with os.fdopen(fd, "w") as f: f.write(json.dumps(data, indent=2) + "\n") os.chmod(PROFILES_FILE, 0o600) def migrate_env_to_profile() -> None: """Migrate ~/.env.jira to ~/.jira/profiles.json.""" if not DEFAULT_ENV_FILE.exists(): error(f"No env file found at {DEFAULT_ENV_FILE}") sys.exit(EXIT_VALIDATION_FAILED) if PROFILES_FILE.exists(): click.echo(f"⚠ Profiles file already exists: {PROFILES_FILE}") if not click.confirm("Add legacy config as 'default' profile?", default=False): click.echo("\nMigration cancelled.") sys.exit(EXIT_USER_ABORT) config = load_env() url = config.get("JIRA_URL", "") profile_data = {"url": url} if config.get("JIRA_PERSONAL_TOKEN"): profile_data["auth"] = "pat" profile_data["token"] = config["JIRA_PERSONAL_TOKEN"] else: profile_data["auth"] = "cloud" profile_data["username"] = config.get("JIRA_USERNAME", "") profile_data["api_token"] = config.get("JIRA_API_TOKEN", "") profile_data["projects"] = [] write_profile("default", profile_data) success(f"Migrated {DEFAULT_ENV_FILE} → {PROFILES_FILE} (profile: 'default')") click.echo() click.echo("You can now add project keys to the profile:") click.echo(f' Edit {PROFILES_FILE} and add project keys to "projects": []') @click.command() @click.option("--url", help="Jira instance URL (will prompt if not provided)") @click.option( "--type", "jira_type", type=click.Choice(["cloud", "server", "auto"]), default="auto", help="Jira deployment type" ) @click.option( "--output", "-o", type=click.Path(), default=str(DEFAULT_ENV_FILE), help=f"Output file path (default: {DEFAULT_ENV_FILE})", ) @click.option("--force", "-f", is_flag=True, help="Overwrite existing file without prompting") @click.option("--test-only", is_flag=True, help="Test credentials without saving") @click.option("--profile", "-P", help="Save as named profile in ~/.jira/profiles.json") @click.option("--projects", help="Comma-separated project keys for the profile") @click.option("--migrate", is_flag=True, help="Migrate ~/.env.jira to profiles.json") def main( url: str | None, jira_type: str, output: str, force: bool, test_only: bool, profile: str | None, projects: str | None, migrate: bool, ): """Interactive Jira credential setup. Guides you through configuring Jira authentication credentials and validates them before saving to ~/.env.jira or ~/.jira/profiles.json. Supports both Jira Cloud (username + API token) and Jira Server/Data Center (Personal Access Token). \b Examples: # Interactive setup (legacy env file) uv run scripts/core/jira-setup.py # Setup as named profile uv run scripts/core/jira-setup.py --profile mkk --url https://jira.example.com # Pre-fill URL uv run scripts/core/jira-setup.py --url https://company.atlassian.net # Test credentials without saving uv run scripts/core/jira-setup.py --test-only # Migrate existing .env.jira to profiles.json uv run scripts/core/jira-setup.py --migrate """ # Handle migration mode if migrate: click.echo() click.echo("=" * 60) click.echo(" Migrate ~/.env.jira → ~/.jira/profiles.json") click.echo("=" * 60) click.echo() migrate_env_to_profile() sys.exit(EXIT_SUCCESS) click.echo() click.echo("=" * 60) if profile: click.echo(f" Jira Profile Setup: {profile}") else: click.echo(" Jira Credential Setup") click.echo("=" * 60) click.echo() # For non-profile mode, check existing env file if not profile: output_path = Path(output) if output_path.exists() and not force and not test_only: click.echo(f"⚠ Configuration file already exists: {output_path}") if not click.confirm("Do you want to overwrite it?", default=False): click.echo("\nSetup cancelled.") sys.exit(EXIT_USER_ABORT) click.echo() # Step 1: Get Jira URL click.echo("Step 1: Jira Instance URL") click.echo("-" * 40) if url: click.echo(f"Using provided URL: {url}") else: click.echo("Enter your Jira instance URL.") click.echo("Examples:") click.echo(" - https://company.atlassian.net (Jira Cloud)") click.echo(" - https://jira.company.com (Jira Server/DC)") click.echo() url = click.prompt("Jira URL", type=str).strip().rstrip("/") # Warn about non-HTTPS URLs if url.startswith("http://") and not url.startswith("http://localhost"): warning("Using HTTP without TLS. Credentials will be transmitted in plaintext.") # Validate URL click.echo() click.echo("Validating URL...", nl=False) url_ok, url_msg = validate_url(url) if url_ok: click.echo(f" ✓ {url_msg}") else: click.echo(" ✗") error(f"URL validation failed: {url_msg}") sys.exit(EXIT_VALIDATION_FAILED) # Step 2: Detect/confirm Jira type click.echo() click.echo("Step 2: Authentication Type") click.echo("-" * 40) if jira_type == "auto": detected = detect_jira_type(url) click.echo(f"Detected Jira type: {detected.upper()}") if detected == "cloud": click.echo(" → Using Username + API Token authentication") else: click.echo(" → Using Personal Access Token (PAT) authentication") if not click.confirm("Is this correct?", default=True): jira_type = click.prompt("Select type", type=click.Choice(["cloud", "server"]), default=detected) else: jira_type = detected click.echo() # Step 3: Get credentials click.echo("Step 3: Credentials") click.echo("-" * 40) config = {"JIRA_URL": url} if jira_type == "cloud": click.echo("Jira Cloud authentication requires:") click.echo(" 1. Your Atlassian account email") click.echo(" 2. An API token (create at https://id.atlassian.com/manage-profile/security/api-tokens)") click.echo() username = click.prompt("Email address", type=str).strip() api_token = click.prompt("API Token", type=str, hide_input=True).strip() config["JIRA_USERNAME"] = username config["JIRA_API_TOKEN"] = api_token # Validate click.echo() click.echo("Validating credentials...", nl=False) cred_ok, cred_msg = validate_credentials(url, "cloud", username=username, api_token=api_token) else: click.echo("Jira Server/Data Center authentication requires:") click.echo(" - A Personal Access Token (PAT)") click.echo(" - Create one in Jira: Profile → Personal Access Tokens → Create token") click.echo() personal_token = click.prompt("Personal Access Token", type=str, hide_input=True).strip() config["JIRA_PERSONAL_TOKEN"] = personal_token # Validate click.echo() click.echo("Validating credentials...", nl=False) cred_ok, cred_msg = validate_credentials(url, "server", personal_token=personal_token) if cred_ok: click.echo(" ✓") success(f"Authenticated as: {cred_msg}") else: click.echo(" ✗") error(f"Authentication failed: {cred_msg}") if jira_type == "cloud": click.echo() click.echo("Troubleshooting tips:") click.echo(" 1. Verify your email address is correct") click.echo(" 2. Generate a new API token at:") click.echo(" https://id.atlassian.com/manage-profile/security/api-tokens") click.echo(" 3. Make sure you're using the token, not your password") else: click.echo() click.echo("Troubleshooting tips:") click.echo(" 1. Create a new PAT in Jira: Profile → Personal Access Tokens") click.echo(" 2. Ensure the token has not expired") click.echo(" 3. Check that you have access to the Jira instance") sys.exit(EXIT_VALIDATION_FAILED) # Step 4: Save configuration if test_only: click.echo() click.echo("=" * 60) success("Credentials validated successfully!") click.echo("(--test-only mode: not saving to file)") sys.exit(EXIT_SUCCESS) click.echo() click.echo("Step 4: Save Configuration") click.echo("-" * 40) if profile: # Profile mode: save to ~/.jira/profiles.json click.echo(f"Profile '{profile}' will be saved to: {PROFILES_FILE}") click.echo("File permissions will be set to 600 (owner read/write only)") # Get project keys project_list = [] if projects: project_list = [p.strip() for p in projects.split(",") if p.strip()] else: click.echo() proj_input = click.prompt( "Project keys (comma-separated, e.g. WEB,INFRA)", type=str, default="", show_default=False ).strip() if proj_input: project_list = [p.strip() for p in proj_input.split(",") if p.strip()] if click.confirm("Save profile?", default=True): profile_data = {"url": url} if jira_type == "cloud": profile_data["auth"] = "cloud" profile_data["username"] = config["JIRA_USERNAME"] profile_data["api_token"] = config["JIRA_API_TOKEN"] else: profile_data["auth"] = "pat" profile_data["token"] = config["JIRA_PERSONAL_TOKEN"] if project_list: profile_data["projects"] = project_list write_profile(profile, profile_data) click.echo() click.echo("=" * 60) success(f"Profile '{profile}' saved to {PROFILES_FILE}") click.echo() click.echo("You can now use the Jira CLI scripts:") click.echo(f" uv run scripts/core/jira-validate.py --profile {profile} --verbose") click.echo(f" uv run scripts/core/jira-issue.py --profile {profile} get PROJ-123") if project_list: click.echo(f"\n Auto-resolution enabled for projects: {', '.join(project_list)}") else: click.echo("\nProfile not saved.") sys.exit(EXIT_USER_ABORT) else: # Legacy mode: save to env file output_path = Path(output) click.echo(f"Configuration will be saved to: {output_path}") click.echo("File permissions will be set to 600 (owner read/write only)") if click.confirm("Save configuration?", default=True): write_env_file(output_path, config) click.echo() click.echo("=" * 60) success(f"Configuration saved to {output_path}") click.echo() click.echo("You can now use the Jira CLI scripts:") click.echo(" uv run scripts/core/jira-validate.py --verbose") click.echo(" uv run scripts/core/jira-issue.py get PROJ-123") else: click.echo("\nConfiguration not saved.") sys.exit(EXIT_USER_ABORT) sys.exit(EXIT_SUCCESS) if __name__ == "__main__": main() -
jira-validate.py 14.1 KB
#!/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() -
jira-worklog.py 13.8 KB
#!/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 worklog operations - add and list time tracking entries.""" import sys from datetime import datetime 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 re import click from lib.client import LazyJiraClient from lib.markup_cli import MarkupGates, guard_wiki_markup, markup_options from lib.output import comment_to_text, error, format_output, success from lib.users import check_mentions_cli, person_label # Trailing UTC offset in any ISO-8601 spelling: "Z", "+01:00" or "+0100". # The hour and minute ranges are part of the match on purpose: `\d{2}` would # accept "+25:00" and rewrite it to "+2500", which is neither valid nor the # untouched passthrough this function promises for input it cannot read. _TZ_SUFFIX_RE = re.compile(r"(?:(?P<utc>[Zz])|(?P<sign>[+-])(?P<hh>[01]\d|2[0-3]):?(?P<mm>[0-5]\d))$") # Date and time to the second, with an optional fractional part of any length. _DATE_TIME_RE = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?$") def _local_offset_at(naive: datetime) -> str: """The compact local UTC offset **for that wall-clock instant**. Not `datetime.now()`: booking `--started 2025-01-15T09:00` in July from a DST zone must record January's offset, not July's. Reading the offset at call time puts the entry an hour out for exactly half the year, silently, and `started` is the field this whole helper exists to get right. A naive datetime's ``astimezone()`` interprets it as local time and resolves the zone for its own date, which is the rule wanted here. Ambiguous and non-existent wall times inside a DST transition resolve to the platform's choice rather than raising — one hour, once a year, against the alternative of refusing a timestamp Jira would have accepted. """ return naive.astimezone().strftime("%z") def normalize_iso_timestamp(timestamp: str) -> str: """Normalize ISO timestamp to Jira's required format. Jira requires: YYYY-MM-DDTHH:MM:SS.sss+ZZZZ (e.g., 2025-01-15T09:00:00.000+0100) Accepts various formats: - 2025-01-15T09:00:00 (adds local timezone) - 2025-01-15T09:00 (adds seconds and local timezone) - 2025-01-15 (adds time 00:00:00 and local timezone) - 2025-01-15T09:00:00+01:00 (converts timezone format) - 2025-01-15T09:00:00.123456+01:00 (truncates to milliseconds) - 2025-01-15T09:00:00Z (Z is +0000, a spelling Jira itself rejects) - 2025-01-15T09:00:00.000+0100 (pass through) A value carrying no offset is anchored to the local zone **as of its own date**, not as of now. Anything unrecognised is returned exactly as the caller typed it, so an odd shape reaches Jira intact rather than half-rewritten. """ # Already in Jira format (has milliseconds and compact timezone) if re.match(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{4}$", timestamp): return timestamp # Date only: 2025-01-15 — midnight local, on that date's offset. if re.match(r"^\d{4}-\d{2}-\d{2}$", timestamp): # The shape matches but the date need not exist (2025-02-30). Parsing is # the first step here that can reject input, and the contract for input # this function cannot read is to hand it back, not to abort the command. try: midnight = datetime.strptime(timestamp, "%Y-%m-%d") except ValueError: return timestamp return f"{timestamp}T00:00:00.000{_local_offset_at(midnight)}" # Split the offset off the body; a bare body inherits local time. tz_match = _TZ_SUFFIX_RE.search(timestamp) if tz_match: body = timestamp[: tz_match.start()] if tz_match.group("utc"): tz_compact = "+0000" else: tz_compact = f"{tz_match.group('sign')}{tz_match.group('hh')}{tz_match.group('mm')}" else: body, tz_compact = timestamp, None # No seconds: 2025-01-15T09:00 if re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$", body): body = f"{body}:00" # Seconds, with or without a fractional part — Jira takes exactly 3 digits. dt_match = _DATE_TIME_RE.match(body) if dt_match: if tz_compact is None: try: parsed = datetime.strptime(dt_match.group(1), "%Y-%m-%dT%H:%M:%S") except ValueError: return timestamp tz_compact = _local_offset_at(parsed) millis = (dt_match.group(2) or "").ljust(3, "0")[:3] return f"{dt_match.group(1)}.{millis}{tz_compact}" # Fallback: the original input, offset included (let Jira handle/reject it) return timestamp # ═══════════════════════════════════════════════════════════════════════════════ # 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 worklog operations. Add and list time tracking entries for Jira issues. TIME_SPENT format examples: '2h', '2h 30m', '1d', '30m' (passed directly to Jira API - see D10) """ 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) # Kept for callers that resolve the config themselves rather than through # the client - the render preview does. Without them guard_wiki_markup # reads None and previews against the DEFAULT profile, which is a # different tenant from the one this command is writing to. ctx.obj["env_file"] = env_file ctx.obj["profile"] = profile @cli.command() @click.argument("issue_key") @click.argument("time_spent") @click.option("--comment", "-c", help="Worklog comment") @click.option( "--started", help="Start time (ISO format: YYYY-MM-DD, YYYY-MM-DDTHH:MM, or YYYY-MM-DDTHH:MM:SS; default: now)" ) @click.option("--no-verify-mentions", is_flag=True, help="Skip [~username] mention verification in --comment") @markup_options @click.pass_context def add( ctx, issue_key: str, time_spent: str, comment: str | None, started: str | None, no_verify_mentions: bool, gates: MarkupGates, ): """Add worklog entry to an issue. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) TIME_SPENT: Time spent in Jira format (e.g., '2h 30m', '1d', '30m') Examples: jira-worklog add PROJ-123 "2h 30m" -c "Code review" jira-worklog add PROJ-123 "1d" --started "2025-01-15T09:00:00" """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] # A worklog comment renders wiki markup — same gates as jira-comment add comment = guard_wiki_markup( comment, gates=gates, issue_key=issue_key, env_file=ctx.obj.get("env_file"), profile=ctx.obj.get("profile"), label="worklog comment", ) check_mentions_cli(client, comment, skip=no_verify_mentions) try: # Build worklog data for JSON API worklog_data = { "timeSpent": time_spent, } if comment: worklog_data["comment"] = comment if started: worklog_data["started"] = normalize_iso_timestamp(started) else: # Default to current time in local timezone (Jira format) worklog_data["started"] = datetime.now().astimezone().strftime("%Y-%m-%dT%H:%M:%S.000%z") # Add worklog via REST API (using issue_add_json_worklog which accepts timeSpent string) result = client.issue_add_json_worklog(issue_key, worklog_data) if ctx.obj["quiet"]: print(result.get("id", "ok")) elif ctx.obj["json"]: format_output(result, as_json=True) else: success(f"Added worklog to {issue_key}: {time_spent}") if comment: print(f" Comment: {comment}") print(f" Worklog ID: {result.get('id', 'N/A')}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to add worklog to {issue_key}: {e}") sys.exit(1) @cli.command("list") @click.argument("issue_key") @click.option("--limit", "-n", default=10, help="Max entries to show") @click.option("--truncate", type=int, metavar="N", help="Truncate comments to N characters") @click.pass_context def list_worklogs(ctx, issue_key: str, limit: int, truncate: int | None): """List worklog entries for an issue. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) Examples: jira-worklog list PROJ-123 jira-worklog list PROJ-123 --limit 5 --json """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: result = client.issue_get_worklog(issue_key) worklogs = result.get("worklogs", []) # Newest first, then limit worklogs = list(reversed(worklogs))[:limit] if ctx.obj["json"]: format_output(worklogs, as_json=True) elif ctx.obj["quiet"]: for wl in worklogs: print(wl.get("id", "")) else: if not worklogs: print(f"No worklogs found for {issue_key}") else: print(f"Worklogs for {issue_key} ({len(worklogs)} shown):\n") for wl in worklogs: author = person_label(wl.get("author")) time_spent = wl.get("timeSpent", "N/A") started = wl.get("started", "N/A")[:10] if wl.get("started") else "N/A" worklog_id = wl.get("id", "N/A") comment = comment_to_text(wl.get("comment")) print(f" [{started}] {author}: {time_spent} (id {worklog_id})") if comment: # Truncate if requested if truncate and len(comment) > truncate: comment = comment[: truncate - 3] + "..." print(f" {comment}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get worklogs for {issue_key}: {e}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.argument("worklog_id") @click.option("--dry-run", is_flag=True, help="Show what would be deleted without deleting") @click.pass_context def delete(ctx, issue_key: str, worklog_id: str, dry_run: bool): """Delete a worklog entry from an issue. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) WORKLOG_ID: The numeric worklog id, as printed by `add` and `list` Use this to undo a booking made against the wrong issue, the wrong duration, or the wrong system — e.g. when the team's system of record is a separate time tracker that syncs its own entries into Jira, and a direct Jira worklog would double-book. Deleting another user's worklog requires the "Delete All Worklogs" permission; your own needs "Delete Own Worklogs". Examples: jira-worklog delete PROJ-123 409062 --dry-run jira-worklog delete PROJ-123 409062 """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: # Fetch the entry first so the operator sees which booking is going # away — a bare numeric id is easy to mistype and impossible to sanity # check after the fact. existing = client.get(f"rest/api/2/issue/{issue_key}/worklog/{worklog_id}") or {} author = person_label(existing.get("author")) time_spent = existing.get("timeSpent", "N/A") started = existing.get("started", "N/A")[:10] if existing.get("started") else "N/A" if dry_run: print(f"Would delete worklog {worklog_id} from {issue_key}:") print(f" [{started}] {author}: {time_spent}") return client.delete(f"rest/api/2/issue/{issue_key}/worklog/{worklog_id}") if ctx.obj["quiet"]: print(worklog_id) elif ctx.obj["json"]: format_output({"deleted": worklog_id, "issue": issue_key}, as_json=True) else: success(f"Deleted worklog {worklog_id} from {issue_key}") print(f" [{started}] {author}: {time_spent}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to delete worklog {worklog_id} from {issue_key}: {e}") sys.exit(1) if __name__ == "__main__": cli()
-
-
lib
-
changelog.py 7.3 KB
"""Helpers for Jira issue changelog / status-history analysis.""" from datetime import datetime, timedelta from typing import Literal TransitionKind = Literal["into_qa", "reject", "forward", "resolved", "out", "other"] def parse_jira_datetime(s: str) -> datetime: """Parse a Jira ISO-8601 timestamp, including the ``+0000`` variant. Python 3.10's :func:`datetime.fromisoformat` rejects the compact ``+0000`` form Jira emits — normalise to ``+00:00`` first. """ if len(s) >= 5 and s[-5] in "+-" and s[-4:].isdigit(): s = s[:-2] + ":" + s[-2:] return datetime.fromisoformat(s) def extract_status_transitions(issue: dict) -> list[dict]: """Extract status-change transitions from an expanded issue payload. Requires the issue to have been fetched with ``?expand=changelog``. Non-status field changes are ignored. Transitions are returned sorted by timestamp (oldest first) with the timestamp parsed to a timezone-aware :class:`datetime`. Returns: List of dicts ``{"created": datetime, "from": str, "to": str}``. """ transitions: list[dict] = [] histories = issue.get("changelog", {}).get("histories", []) for h in histories: created_raw = h.get("created") if not created_raw: continue try: created = parse_jira_datetime(created_raw) except ValueError: continue for item in h.get("items", []): if item.get("field") != "status": continue transitions.append( { "created": created, "from": item.get("fromString") or "", "to": item.get("toString") or "", } ) transitions.sort(key=lambda t: t["created"]) return transitions def compute_time_in_status( issue_created: datetime, transitions: list[dict], current_status: str, now: datetime, ) -> dict[str, timedelta]: """Sum the time an issue has spent in each status. Args: issue_created: When the issue was created (tz-aware datetime). transitions: Output of :func:`extract_status_transitions`, sorted oldest first. current_status: Status name at ``now`` — used for the final open segment (and as sole segment when the issue has no transitions). now: Reference "now" datetime. Returns: Mapping of status name → total :class:`timedelta` in that status. """ result: dict[str, timedelta] = {} def _add(status: str, delta: timedelta) -> None: if delta.total_seconds() <= 0: delta = timedelta(0) result[status] = result.get(status, timedelta(0)) + delta if not transitions: _add(current_status, now - issue_created) return result # First segment: issue creation → first transition first = transitions[0] initial_status = first["from"] or current_status _add(initial_status, first["created"] - issue_created) # Middle segments: time spent in the status we were in *before* each later transition for i in range(1, len(transitions)): prev = transitions[i - 1] curr = transitions[i] # The status between prev and curr is prev["to"] (also curr["from"]) status = prev["to"] or curr["from"] or current_status _add(status, curr["created"] - prev["created"]) # Final open segment: last transition → now last = transitions[-1] _add(last["to"] or current_status, now - last["created"]) return result def extract_status_transitions_with_authors(issue: dict) -> list[dict]: """Like :func:`extract_status_transitions` but preserves transition author. Each entry adds ``author_name`` (display name) and ``author_key`` (the stable identifier — Server/DC ``name`` or Cloud ``accountId``). """ transitions: list[dict] = [] histories = issue.get("changelog", {}).get("histories", []) for h in histories: created_raw = h.get("created") if not created_raw: continue try: created = parse_jira_datetime(created_raw) except ValueError: continue author = h.get("author") or {} author_name = author.get("displayName", "") author_key = author.get("name") or author.get("accountId") or "" for item in h.get("items", []): if item.get("field") != "status": continue transitions.append( { "created": created, "from": item.get("fromString") or "", "to": item.get("toString") or "", "author_name": author_name, "author_key": author_key, } ) transitions.sort(key=lambda t: t["created"]) return transitions def classify_transition(transition: dict, status_sets: dict) -> "TransitionKind": """Classify a status transition against qa/working/resolved sets. Returns one of: * ``into_qa`` — moving from a non-QA state into a QA state (handover) * ``reject`` — moving from a QA state back to a working state (fail) * ``forward`` — moving between two distinct QA states (e.g. QA→QA2, Review→UAT) — a multi-stage QA progression, NOT a fail * ``resolved`` — terminal success (any state → resolved set) * ``out`` — leaving QA into something not classified above * ``other`` — neither side touches QA """ qa = status_sets["qa"] working = status_sets["working"] resolved = status_sets["resolved"] src = transition["from"] dst = transition["to"] if dst in resolved: return "resolved" src_in_qa = src in qa dst_in_qa = dst in qa if not src_in_qa and dst_in_qa: return "into_qa" if src_in_qa and dst in working: return "reject" if src_in_qa and dst_in_qa and src != dst: return "forward" if src_in_qa and not dst_in_qa: return "out" return "other" def find_transition_window(transitions: list[dict], target_index: int) -> tuple[datetime | None, datetime | None]: """Return (T_prev, T_next) bracketing ``transitions[target_index]``. Both endpoints are *other* status changes, ignoring same-second duplicates. Either may be ``None`` if no bracketing transition exists. """ if not (0 <= target_index < len(transitions)): return None, None target_t = transitions[target_index]["created"] t_prev = None for t in reversed(transitions[:target_index]): if t["created"] < target_t: t_prev = t["created"] break t_next = None for t in transitions[target_index + 1 :]: if t["created"] > target_t: t_next = t["created"] break return t_prev, t_next def format_timedelta(delta: timedelta) -> str: """Format a :class:`timedelta` as a short human-readable string. Examples: ``3d 4h``, ``5h 30m``, ``42m``, ``0m``. Negative durations are clamped to ``0m``. """ total = int(delta.total_seconds()) if total <= 0: return "0m" days, rem = divmod(total, 86400) hours, rem = divmod(rem, 3600) minutes = rem // 60 if days > 0: return f"{days}d {hours}h" if hours > 0: return f"{hours}h {minutes}m" return f"{minutes}m" -
client.py 26.6 KB
"""Jira client initialization for CLI scripts.""" import re from urllib.parse import urlparse from atlassian import Jira from requests import Response from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry from .config import get_auth_mode, is_cloud_url, load_config, validate_config from .errors import AuthenticationError, CaptchaError, _sanitize_error # noqa: F401 (re-exported) from .users import find_users, is_cloud_client # Default timeout for all Jira API requests (seconds) JIRA_TIMEOUT = 30 # ═══════════════════════════════════════════════════════════════════════════════ # Account ID detection (Jira Cloud) # ═══════════════════════════════════════════════════════════════════════════════ ACCOUNT_ID_PATTERN = re.compile(r"^[a-zA-Z0-9:\-]+$") LEGACY_ACCOUNT_ID_PATTERN = re.compile(r"^[a-f0-9]{24}$") def is_account_id(s: str) -> bool: """Check if a string looks like a Jira Cloud account ID. Cloud account IDs come in two formats: - New format with colon: '557058:d5765ebc-27de-4ce3-b520-a77a87e5e99a' - Legacy 24-char hex: '5b10ac8d82e05b22cc7d4ef5' """ if not s: return False if ":" in s: return bool(ACCOUNT_ID_PATTERN.match(s)) return bool(LEGACY_ACCOUNT_ID_PATTERN.match(s)) def resolve_assignee(client, identifier: str) -> dict: """Resolve an assignee identifier to a Jira-API-ready dict. Handles: - "me" (case-insensitive): resolves via client.myself() - Jira Cloud account IDs: returned as {"accountId": ...} - Usernames/emails: searched via user_find_by_user_string() Returns: dict with either {"accountId": ...} or {"name": ...} """ if identifier.lower() == "me": user = client.myself() if "accountId" in user: return {"accountId": user["accountId"]} return {"name": user.get("name", user.get("key", ""))} if is_account_id(identifier): return {"accountId": identifier} # Exact username lookup first (Server/DC) — a fragment search must never # silently pick a user when the identifier already names one exactly. if not is_cloud_client(client): try: user = client.user(username=identifier) if isinstance(user, dict) and user.get("name"): return {"name": user["name"]} except Exception: # Not an exact username — resolve via search below. pass # Cloud-aware fragment search (Server/DC needs username=, Cloud query=). # Accept an exact field match, or a single unambiguous candidate; with # several fuzzy candidates fall back to the raw identifier so Jira # rejects it visibly instead of us silently assigning an arbitrary user. users = find_users(client, identifier, limit=10) ident_cf = identifier.casefold() exact = [ u for u in users if any(str(u.get(k) or "").casefold() == ident_cf for k in ("name", "key", "emailAddress", "displayName")) ] candidates = exact if exact else (users if len(users) == 1 else []) if candidates: found = candidates[0] if "accountId" in found: return {"accountId": found["accountId"]} return {"name": found.get("name", found.get("key", identifier))} # Fall back to raw identifier — let Jira validate return {"name": identifier} def fetch_comments_paginated(client, issue_key: str, page_size: int = 100) -> tuple[list[dict], int | None]: """Fetch all comments for an issue, paginating through Jira's REST endpoint. The embedded ``fields.comment.comments`` block on an issue payload is truncated by Jira (default 50 entries on Server/DC). For long-running tickets that truncation silently drops comments. This helper hits ``/rest/api/2/issue/{key}/comment`` with explicit ``startAt`` / ``maxResults`` until the server signals it has returned everything. Returns ``(comments, total)`` where ``total`` is the Jira-reported total if available, or ``None`` if the server omits it. """ comments: list[dict] = [] start_at = 0 total: int | None = None while True: payload = ( client.get( f"rest/api/2/issue/{issue_key}/comment", params={"startAt": start_at, "maxResults": page_size}, ) or {} ) values = payload.get("comments", []) or [] if total is None: raw_total = payload.get("total") total = raw_total if isinstance(raw_total, int) else None comments.extend(values) if not values: break if total is not None and (start_at + len(values)) >= total: break start_at += len(values) return comments, total def get_project_issue_types(client, project_key: str, subtask_only: bool | None = None) -> list[dict]: """Get available issue types for a project. Uses expand=issueTypes to ensure Server/DC includes type metadata. Args: client: Jira client instance project_key: Project key (e.g., "PROJ") subtask_only: If True, only subtask types. If False, only non-subtask. None = all. Returns: List of issue type dicts with at least 'id', 'name', 'subtask' keys. """ project = client.project(project_key, expand="issueTypes") types = project.get("issueTypes", []) if subtask_only is True: return [t for t in types if t.get("subtask")] if subtask_only is False: return [t for t in types if not t.get("subtask")] return types def resolve_status(client, identifier: str) -> str: """Resolve a status identifier to a canonical Jira status name. Uses `GET /rest/api/2/status` and matches: 1. Exact (case-insensitive) on status name → return canonical name 2. Substring match (unambiguous — exactly one hit) → return canonical name 3. Otherwise → raise ValueError with candidates The same status name may appear multiple times in the API response (same workflow status used across several workflows). Names are deduplicated before matching. Args: client: Jira client instance (must support `.get(path)`). identifier: User-supplied status string to resolve. Returns: Canonical status name with original casing from Jira. Raises: ValueError: No match, or substring match is ambiguous. """ response = client.get("rest/api/2/status") or [] if not isinstance(response, list): response = [] names: set[str] = {s["name"] for s in response if isinstance(s, dict) and s.get("name")} if not names: raise ValueError("No statuses available from Jira") ident_lower = identifier.lower() # 1. Exact match (case-insensitive) for name in names: if name.lower() == ident_lower: return name # 2. Substring match (unambiguous) substring_matches = sorted(n for n in names if ident_lower in n.lower()) if len(substring_matches) == 1: return substring_matches[0] if len(substring_matches) > 1: raise ValueError(f"Status '{identifier}' is ambiguous. Candidates: {', '.join(substring_matches)}") # 3. No match raise ValueError(f"Status '{identifier}' not found. Available: {', '.join(sorted(names))}") def resolve_subtask_type(client, project_key: str, requested_type: str) -> str | None: """Resolve a requested issue type to a valid subtask type for the project. Resolution order: 1. Exact match (case-insensitive) among subtask types 2. Subtask type whose name contains the requested type (e.g., "Task" → "Sub: Task") 3. Generic subtask keywords ("subtask", "sub-task") → first available subtask type 4. Only one subtask type available → return it regardless of name 5. No match / no subtask types → return None Args: client: Jira client instance project_key: Project key requested_type: The issue type name the user requested Returns: Resolved subtask type name, or None if no match or no subtask types. """ subtask_types = get_project_issue_types(client, project_key, subtask_only=True) if not subtask_types: return None req_lower = requested_type.lower() # 1. Exact match (case-insensitive) for st in subtask_types: if st["name"].lower() == req_lower: return st["name"] # 2. Subtask type whose name contains the requested type (unambiguous only) # e.g., "Task" matches "Sub: Task", "Bug" matches "Sub: Bug" substring_matches = [st for st in subtask_types if req_lower in st["name"].lower()] if len(substring_matches) == 1: return substring_matches[0]["name"] # 3. Generic subtask keywords → first available if req_lower in ("subtask", "sub-task", "sub task"): return subtask_types[0]["name"] # 4. Only one subtask type? Use it (unambiguous). if len(subtask_types) == 1: return subtask_types[0]["name"] return None # === INLINE_START: client === class SessionExpiredError(Exception): """Raised when a 200 OK response carries an HTML session-expiry or login page. Jira Server/DC redirects expired sessions to a login page that returns 200 OK with Content-Type: text/html and no Content-Disposition: attachment. Without this check the HTML body would be silently written to disk as if it were the requested file. """ def _check_captcha_challenge(response: Response, jira_url: str) -> None: """Check response for CAPTCHA challenge and raise exception if found. Jira Server/DC may require CAPTCHA resolution after failed login attempts. This is indicated by the X-Authentication-Denied-Reason header containing 'CAPTCHA_CHALLENGE'. Args: response: HTTP response to check jira_url: Base Jira URL for constructing login URL Raises: CaptchaError: If CAPTCHA challenge is detected """ header_name = "X-Authentication-Denied-Reason" if header_name not in response.headers: return header_value = response.headers[header_name] if "CAPTCHA_CHALLENGE" not in header_value: return # Extract login URL if present in header, but validate it matches jira_url host login_url = f"{jira_url}/login.jsp" if "; login-url=" in header_value: candidate = header_value.split("; login-url=")[1].strip() # Only use the header URL if its host matches the configured Jira host candidate_host = urlparse(candidate).netloc.lower() jira_host = urlparse(jira_url).netloc.lower() if candidate_host == jira_host: login_url = candidate raise CaptchaError( f"CAPTCHA challenge detected!\n\n" f" Jira requires you to solve a CAPTCHA before API access is allowed.\n\n" f" To resolve:\n" f" 1. Open {login_url} in your web browser\n" f" 2. Log in and complete the CAPTCHA challenge\n" f" 3. Retry this command\n\n" f" This typically happens after several failed login attempts.", login_url=login_url, ) def _check_session_expiry(response: Response, url: str) -> None: """Check whether a 200 OK response is actually an HTML session-expiry page. Jira Server/DC returns 200 OK with Content-Type: text/html when the session has expired and the request is redirected to a login page. Real HTML file attachments are distinguished by the presence of Content-Disposition: attachment. Args: response: HTTP response to check url: Effective URL of the response, used in the error message Raises: SessionExpiredError: If the response looks like a login/session-expiry page """ if response.status_code != 200: return ct = response.headers.get("Content-Type", "").split(";")[0].strip().lower() if not ct.startswith("text/html"): return cd = response.headers.get("Content-Disposition", "").lower() if "attachment" in cd: return raise SessionExpiredError( f"Request failed: response is HTML without an attachment disposition " f"(Content-Type: {ct}, URL: {url}). " "The session may have expired or the URL redirected to a login page." ) def _check_authentication(response: Response) -> None: """Check whether the response signals an authentication failure. Raises AuthenticationError for 401 and 403 responses. Must be called after _check_captcha_challenge so that CAPTCHA-flavoured 401s (which carry the X-Authentication-Denied-Reason header) are attributed to CaptchaError rather than the more generic AuthenticationError. Args: response: HTTP response to check Raises: AuthenticationError: If the response status is 401 or 403 """ if response.status_code in (401, 403): raise AuthenticationError( f"Authentication failed (HTTP {response.status_code}). Check your credentials or token." ) def _handle_response(response: Response, jira_url: str, url: str | None = None) -> None: """Run all response-level validation checks. Centralises CAPTCHA detection, authentication failure detection, and session-expiry detection so every request path benefits from the same guards regardless of whether it goes through the patched Jira session or a bare requests.get call. Call order matters: 1. CAPTCHA — fires on 401 carrying X-Authentication-Denied-Reason header 2. Authentication — fires on any remaining 401/403 3. Session expiry — fires on 200 + text/html without attachment disposition Args: response: HTTP response to validate jira_url: Base Jira URL, used for CAPTCHA login URL construction url: Effective request URL for error messages; falls back to response.url when not supplied """ _check_captcha_challenge(response, jira_url) _check_authentication(response) _check_session_expiry(response, url or getattr(response, "url", "")) def _patch_session_for_response_validation(client: Jira, jira_url: str) -> None: """Patch the Jira client session to run response validation on every request. The atlassian-python-api library doesn't inspect responses for CAPTCHA challenges, authentication failures, or session-expiry HTML pages, so we wrap the session's request method to call _handle_response after each response is received. Args: client: Jira client instance to patch jira_url: Base Jira URL for error messages """ original_request = client._session.request def patched_request(method: str, url: str, **kwargs) -> Response: response = original_request(method, url, **kwargs) _handle_response(response, jira_url, url=url) return response client._session.request = patched_request class LazyJiraClient: """Deferred Jira client that supports issue-key/URL-based profile resolution. Stores connection parameters and creates the actual Jira client lazily on first attribute access. CLI subcommands can call with_context() to provide issue_key/url for automatic profile resolution before the first API call. Also overrides ``jql()`` to route Atlassian Cloud calls to the ``/rest/api/3/search/jql`` endpoint introduced by CHANGE-2046 — the library is pinned to api/2 which Atlassian removed from Cloud. Server/DC paths delegate to the library unchanged. Usage in CLI scripts:: # Group callback — no connection made yet ctx.obj['client'] = LazyJiraClient(env_file=env_file, profile=profile) # Subcommand — provide issue_key context, then use normally ctx.obj['client'].with_context(issue_key=issue_key) client = ctx.obj['client'] client.issue(issue_key) # Client created here on first access """ # Maximum issues to drain inside a single jql() call on Cloud before # bailing out, regardless of caller's start+limit. Prevents accidental # runaway pagination when a caller passes a very large start offset. _CLOUD_DRAIN_HARDCAP = 1000 _CLOUD_SEARCH_ENDPOINT = "rest/api/3/search/jql" def __init__(self, env_file: str | None = None, profile: str | None = None): object.__setattr__(self, "_env_file", env_file) object.__setattr__(self, "_profile", profile) object.__setattr__(self, "_issue_key", None) object.__setattr__(self, "_url", None) object.__setattr__(self, "_client", None) def with_context(self, issue_key: str | None = None, url: str | None = None): """Set resolution context for automatic profile matching. Must be called before the first API call. Has no effect if the client is already initialized. If *issue_key* looks like a URL (starts with http(s)://), it is also used as *url* for host-based profile resolution. """ if object.__getattribute__(self, "_client") is None: if issue_key is not None: object.__setattr__(self, "_issue_key", issue_key) # Detect URL passed as issue_key → enable host-based resolution if url is None and issue_key.startswith(("http://", "https://")): object.__setattr__(self, "_url", issue_key) if url is not None: object.__setattr__(self, "_url", url) return self def _ensure_client(self) -> Jira: """Lazy-create the underlying Jira client on first use.""" client = object.__getattribute__(self, "_client") if client is None: client = get_jira_client( env_file=object.__getattribute__(self, "_env_file"), profile=object.__getattribute__(self, "_profile"), issue_key=object.__getattribute__(self, "_issue_key"), url=object.__getattribute__(self, "_url"), ) object.__setattr__(self, "_client", client) return client def __getattr__(self, name): return getattr(self._ensure_client(), name) def jql(self, jql: str, limit: int = 50, start: int = 0, fields=None, **kwargs) -> dict: """Execute a JQL search, routing Cloud to /rest/api/3/search/jql. On Atlassian Cloud the library's ``jql()`` hits ``/rest/api/2/search`` which Atlassian removed (CHANGE-2046). This override calls the new cursor-paginated ``/rest/api/3/search/jql`` endpoint directly and translates the cursor-based response back to the offset/total shape the callers expect (``issues``, ``startAt``, ``maxResults``, ``total``, ``isLast``), so ``jira-search``, ``jira-qa-gather`` and ``jira-worklog-query`` keep working without changes. Synthesized ``total`` semantics: * On the final page (``isLast=True``): ``total == len(collected)`` — accurate count of the matching result set up to the drained window. * Otherwise: ``total == start + len(sliced) + 1`` — a sentinel that tells callers paginating via ``start_at`` that more pages exist. Inefficiency note: each call drains pages from index 0 up to ``start + limit`` because the new endpoint is cursor-based and we cannot resume from an offset. Callers that paginate over many pages incur quadratic total request cost. Acceptable as a workaround until ``atlassian-python-api`` ships Cloud-aware ``jql()``; see ``TODO(CHANGE-2046)`` below for the migration path. On Server/DC the library's ``jql()`` remains functional via api/2 and is called unchanged. """ # TODO(CHANGE-2046): Remove this override once atlassian-python-api # supports Cloud /rest/api/3/search/jql natively (tracked upstream as # https://github.com/atlassian-api/atlassian-python-api/issues/1631). client = self._ensure_client() # URL-based Cloud detection is more robust than the library's ``cloud`` # attribute, which has shifted across versions and is easy to omit in mocks. if not is_cloud_url(getattr(client, "url", "") or ""): return client.jql(jql, fields=fields, start=start, limit=limit, **kwargs) return self._jql_cloud(client, jql, start, limit, fields, expand=kwargs.get("expand")) @staticmethod def _normalize_jql_fields(fields) -> str: """Coerce a fields argument to the comma-separated string the endpoint expects.""" if fields is None: return "*all" if isinstance(fields, (list, tuple, set)): return ",".join(fields) return fields def _jql_cloud( self, client, jql: str, start: int, limit: int, fields, *, expand=None, ) -> dict: """Cloud path: drain cursor-paginated pages until the window is filled.""" fields_str = self._normalize_jql_fields(fields) target = start + limit hardcap = type(self)._CLOUD_DRAIN_HARDCAP collected: list = [] next_token: str | None = None is_last = True # defaults True so an empty initial response yields total=0 while len(collected) < target and len(collected) < hardcap: page = self._fetch_jql_page(client, jql, fields_str, expand, target - len(collected), next_token) page_issues = page.get("issues", []) or [] collected.extend(page_issues) is_last = bool(page.get("isLast", True)) next_token = page.get("nextPageToken") if is_last or not next_token or not page_issues: break return self._synthesize_offset_response(collected, start, limit, is_last) def _fetch_jql_page( self, client, jql: str, fields: str, expand, remaining: int, next_token: str | None, ) -> dict: """Fetch a single page from /rest/api/3/search/jql, annotating errors with context.""" params: dict = { "jql": jql, "maxResults": min(100, max(1, remaining)), "fields": fields, } if next_token is not None: params["nextPageToken"] = next_token if expand is not None: params["expand"] = expand try: return client.get(type(self)._CLOUD_SEARCH_ENDPOINT, params=params) or {} except Exception as exc: if hasattr(exc, "add_note"): # Python 3.11+ exc.add_note(f"Cloud override: {type(self)._CLOUD_SEARCH_ENDPOINT} (CHANGE-2046)") raise @staticmethod def _synthesize_offset_response(collected: list, start: int, limit: int, is_last: bool) -> dict: """Translate cursor-drained issues into the offset/total response shape callers expect.""" target = start + limit sliced = collected[start:target] total = len(collected) if is_last else start + len(sliced) + 1 return { "issues": sliced, "startAt": start, "maxResults": limit, "total": total, "isLast": is_last and len(collected) <= target, } def get_jira_client( env_file: str | None = None, profile: str | None = None, issue_key: str | None = None, url: str | None = None ) -> Jira: """Initialize and return a Jira client. Supports two authentication modes: - Cloud: JIRA_USERNAME + JIRA_API_TOKEN - Server/DC: JIRA_PERSONAL_TOKEN (Personal Access Token) When profiles.json exists and no env_file is specified, uses profile resolution to determine the correct Jira instance. Args: env_file: Optional path to environment file (takes precedence over profiles) profile: Optional profile name from ~/.jira/profiles.json issue_key: Optional issue key for automatic profile resolution url: Optional Jira URL for automatic profile resolution Returns: Configured Jira client instance Raises: FileNotFoundError: If env file doesn't exist ValueError: If configuration is invalid ConnectionError: If cannot connect to Jira """ config = load_config(profile=profile, env_file=env_file, issue_key=issue_key, url=url) errors = validate_config(config) if errors: raise ValueError("Configuration errors:\n " + "\n ".join(errors)) jira_url = config["JIRA_URL"] auth_mode = get_auth_mode(config) # Determine if Cloud or Server/DC is_cloud = config.get("JIRA_CLOUD", "").lower() == "true" if "JIRA_CLOUD" not in config: is_cloud = is_cloud_url(jira_url) try: if auth_mode == "pat": # Server/DC with Personal Access Token client = Jira( url=jira_url, token=config["JIRA_PERSONAL_TOKEN"], cloud=is_cloud, timeout=JIRA_TIMEOUT, ) else: # Cloud with username + API token client = Jira( url=jira_url, username=config["JIRA_USERNAME"], password=config["JIRA_API_TOKEN"], cloud=is_cloud, timeout=JIRA_TIMEOUT, ) # Mount retry adapter for rate limiting (HTTP 429) and transient errors retry_strategy = Retry( total=3, backoff_factor=1, status_forcelist=[429, 502, 503, 504], ) adapter = HTTPAdapter(max_retries=retry_strategy) client._session.mount("https://", adapter) client._session.mount("http://", adapter) # Patch session to run response validation (CAPTCHA, authentication failures, session-expiry HTML pages) _patch_session_for_response_validation(client, jira_url) return client except CaptchaError: # Re-raise CAPTCHA errors with full context raise except Exception as e: # Sanitize error message to prevent credential leakage error_msg = _sanitize_error(str(e)) if auth_mode == "pat": raise ConnectionError( f"Failed to connect to Jira at {jira_url}\n\n" f" Error: {error_msg}\n\n" f" Please verify:\n" f" - JIRA_URL is correct\n" f" - JIRA_PERSONAL_TOKEN is a valid Personal Access Token\n" ) from e else: raise ConnectionError( f"Failed to connect to Jira at {jira_url}\n\n" f" Error: {error_msg}\n\n" f" Please verify:\n" f" - JIRA_URL is correct\n" f" - JIRA_USERNAME is your email (Cloud) or username (Server/DC)\n" f" - JIRA_API_TOKEN is valid\n" ) from e # === INLINE_END: client === -
config.py 15 KB
"""Environment configuration handling for Jira CLI scripts.""" import json import os import re import sys from pathlib import Path from urllib.parse import urlparse # === INLINE_START: config === # Ensure UTF-8 output on Windows — reuse the shared helper so behavior # stays in sync (see output.py for the full rationale). if sys.platform == "win32": from .output import _ensure_utf8_streams _ensure_utf8_streams() def normalize_netloc(url: str) -> str: """Normalize a URL's netloc by lowercasing and stripping default ports.""" parsed = urlparse(url) host = parsed.netloc.lower() scheme = parsed.scheme.lower() if scheme == "https" and host.endswith(":443"): host = host[:-4] elif scheme == "http" and host.endswith(":80"): host = host[:-3] return host DEFAULT_ENV_FILE = Path.home() / ".env.jira" PROFILES_FILE = Path.home() / ".jira" / "profiles.json" # Cloud authentication: JIRA_USERNAME + JIRA_API_TOKEN # Server/DC authentication: JIRA_PERSONAL_TOKEN (PAT) REQUIRED_URL = "JIRA_URL" CLOUD_VARS = ["JIRA_USERNAME", "JIRA_API_TOKEN"] SERVER_VARS = ["JIRA_PERSONAL_TOKEN"] OPTIONAL_VARS = ["JIRA_CLOUD"] ALL_VARS = [REQUIRED_URL] + CLOUD_VARS + SERVER_VARS + OPTIONAL_VARS def load_env(env_file: str | None = None) -> dict: """Load configuration from file with environment variable fallback. Priority order: 1. Explicit env_file parameter (must exist if specified) 2. ~/.env.jira (if exists) 3. Environment variables (fallback for missing values) Supports two authentication modes: - Cloud: JIRA_URL + JIRA_USERNAME + JIRA_API_TOKEN - Server/DC: JIRA_URL + JIRA_PERSONAL_TOKEN Args: env_file: Path to environment file. If specified, file must exist. Returns: Dictionary of configuration values Raises: FileNotFoundError: If explicit env_file doesn't exist """ config = {} path = Path(env_file) if env_file else DEFAULT_ENV_FILE # Load from file if it exists (or raise if explicitly specified but missing) if path.exists(): with open(path) as f: for line in f: line = line.strip() if line and not line.startswith("#") and "=" in line: key, _, value = line.partition("=") key = key.strip() # Strip optional 'export' prefix (bash compatibility) if key.startswith("export "): key = key[7:].strip() config[key] = value.strip().strip('"').strip("'") elif env_file: # Explicit file was specified but doesn't exist raise FileNotFoundError(f"Environment file not found: {path}") # Fill in missing values from environment variables for var in ALL_VARS: if var not in config and var in os.environ: config[var] = os.environ[var] return config def validate_config(config: dict) -> list: """Validate configuration has all required variables. Supports two authentication modes: - Cloud: JIRA_URL + JIRA_USERNAME + JIRA_API_TOKEN - Server/DC: JIRA_URL + JIRA_PERSONAL_TOKEN Args: config: Configuration dictionary Returns: List of validation errors (empty if valid) """ errors = [] # JIRA_URL is always required if REQUIRED_URL not in config or not config[REQUIRED_URL]: errors.append(f"Missing required variable: {REQUIRED_URL}") # Validate URL format if REQUIRED_URL in config and config[REQUIRED_URL]: url = config[REQUIRED_URL] if not url.startswith(("http://", "https://")): errors.append(f"JIRA_URL must start with http:// or https://: {url}") # Check for valid authentication configuration has_cloud_auth = all(config.get(var) for var in CLOUD_VARS) has_server_auth = config.get("JIRA_PERSONAL_TOKEN") if not has_cloud_auth and not has_server_auth: errors.append( "Missing authentication credentials. Provide either:\n" " - JIRA_USERNAME + JIRA_API_TOKEN (for Cloud)\n" " - JIRA_PERSONAL_TOKEN (for Server/DC)" ) return errors def get_auth_mode(config: dict) -> str: """Determine authentication mode from config. Args: config: Configuration dictionary Returns: 'cloud' for Cloud auth, 'pat' for Personal Access Token """ if config.get("JIRA_PERSONAL_TOKEN"): return "pat" return "cloud" def load_profiles() -> dict: """Load and validate profiles from ~/.jira/profiles.json. Returns: Parsed profiles dictionary Raises: FileNotFoundError: If profiles.json doesn't exist ValueError: If profiles.json is invalid """ if not PROFILES_FILE.exists(): raise FileNotFoundError(f"Profiles file not found: {PROFILES_FILE}") try: data = json.loads(PROFILES_FILE.read_text()) except json.JSONDecodeError as e: raise ValueError(f"Invalid JSON in {PROFILES_FILE}: {e}") from e if not isinstance(data, dict) or "profiles" not in data: raise ValueError(f"Invalid profiles format: missing 'profiles' key in {PROFILES_FILE}") if not isinstance(data["profiles"], dict) or not data["profiles"]: raise ValueError(f"No profiles defined in {PROFILES_FILE}") return data def resolve_profile( issue_key: str | None = None, url: str | None = None, profile: str | None = None, project_dir: str | None = None ) -> dict: """Resolve a Jira profile using the priority algorithm. Priority: 1. Explicit profile name 2. Full Jira URL → match host against profile.url 3. Ticket key → match project prefix against profiles[].projects 4. .jira-profile file in project directory 5. Default profile from profiles.json Args: issue_key: Jira issue key (e.g. WEB-1381) url: Full Jira URL profile: Explicit profile name project_dir: Project directory path to check for .jira-profile Returns: Profile configuration dictionary with 'name' key added Raises: FileNotFoundError: If profiles.json doesn't exist ValueError: If profile cannot be resolved or is ambiguous """ data = load_profiles() profiles = data["profiles"] # Step 1: Explicit profile name if profile: if profile not in profiles: available = ", ".join(sorted(profiles.keys())) raise ValueError(f"Profile '{profile}' not found. Available: {available}") result = dict(profiles[profile]) result["name"] = profile return result # Step 2: Full Jira URL → match host (normalized to strip default ports) if url: input_host = normalize_netloc(url) if input_host: for name, prof in profiles.items(): prof_host = normalize_netloc(prof.get("url", "")) if prof_host and prof_host == input_host: result = dict(prof) result["name"] = name return result # Step 3: Ticket key → match project prefix if issue_key: match = re.match(r"^([A-Z][A-Z0-9_]+)-\d+$", issue_key) if match: prefix = match.group(1) matching_profiles = [] for name, prof in profiles.items(): projects = prof.get("projects") if isinstance(projects, list) and prefix in projects: matching_profiles.append(name) if len(matching_profiles) == 1: result = dict(profiles[matching_profiles[0]]) result["name"] = matching_profiles[0] return result elif len(matching_profiles) > 1: names = ", ".join(sorted(matching_profiles)) raise ValueError(f"{prefix} found in profiles: {names}. Use --profile to disambiguate.") # Step 4: .jira-profile file in project directory if project_dir: profile_file = Path(project_dir) / ".jira-profile" if profile_file.exists(): dir_profile = profile_file.read_text().strip() if dir_profile in profiles: result = dict(profiles[dir_profile]) result["name"] = dir_profile return result else: print( f"⚠ .jira-profile references unknown profile '{dir_profile}', skipping", file=sys.stderr, ) # Step 5: Default profile default_name = data.get("default") if default_name and default_name in profiles: result = dict(profiles[default_name]) result["name"] = default_name return result # No match found available = ", ".join(sorted(profiles.keys())) raise ValueError(f"Could not resolve profile. Available profiles: {available}. Use --profile to specify one.") def is_cloud_url(url: str) -> bool: """Check if a Jira URL points to Atlassian Cloud. Uses strict domain matching: must be exactly 'atlassian.net' or end with '.atlassian.net'. This prevents bypass via malicious domains like 'attacker-atlassian.net.evil.com'. Args: url: Jira instance URL Returns: True if the URL is an Atlassian Cloud instance """ netloc = urlparse(url).netloc.lower() return netloc == "atlassian.net" or netloc.endswith(".atlassian.net") def profile_to_config(prof: dict) -> dict: """Convert a profile dict to the env-style config dict used by the client. Args: prof: Profile dictionary from profiles.json Returns: Config dictionary compatible with validate_config/get_jira_client Raises: ValueError: If required fields are missing """ url = prof.get("url") if not url: raise ValueError("Profile is missing required 'url' field") config = {"JIRA_URL": url} auth = prof.get("auth", "pat") if auth == "cloud": if not prof.get("username") or not prof.get("api_token"): raise ValueError("Profile is missing required 'username' and/or 'api_token' fields for cloud auth") config["JIRA_USERNAME"] = prof["username"] config["JIRA_API_TOKEN"] = prof["api_token"] else: if not prof.get("token"): raise ValueError("Profile is missing required 'token' field for PAT auth") config["JIRA_PERSONAL_TOKEN"] = prof["token"] return config def load_config( profile: str | None = None, env_file: str | None = None, issue_key: str | None = None, url: str | None = None ) -> dict: """Unified configuration loader combining profiles and legacy env files. Priority: 1. Explicit --env-file → legacy env file behavior 2. profiles.json exists → profile resolution 3. Legacy fallback → ~/.env.jira Args: profile: Explicit profile name env_file: Explicit env file path issue_key: Issue key for auto-resolution url: Jira URL for auto-resolution Returns: Config dictionary ready for get_jira_client """ # --env-file always takes precedence if env_file: return load_env(env_file) # Try profile resolution if profiles.json exists if PROFILES_FILE.exists(): try: cwd = os.getcwd() except OSError: cwd = None prof = resolve_profile( issue_key=issue_key, url=url, profile=profile, project_dir=cwd, ) return profile_to_config(prof) # Explicit --profile but no profiles.json if profile: raise FileNotFoundError( f"Profile '{profile}' requested but {PROFILES_FILE} does not exist.\n" f" Run: uv run scripts/core/jira-setup.py --profile {profile}" ) # Legacy fallback return load_env() # ═══════════════════════════════════════════════════════════════════════════════ # Workflow status sets (qa / working / resolved) used by intent verbs # ═══════════════════════════════════════════════════════════════════════════════ DEFAULT_QA_STATUSES = ( "QA", "Review", "In Review", "Code Review", "Ready for QA", "QA2", "UAT", "Acceptance", "Testing", ) # "QA failed" semantically reads like a QA stage, but functionally it's a # reject-target ("review verdict: send back to dev"). Putting it in the working # set means `QA → QA failed` correctly classifies as REJECT rather than FORWARD. DEFAULT_WORKING_STATUSES = ( "In Progress", "Open", "Reopened", "To Do", "In Development", "Backlog", "QA failed", ) DEFAULT_RESOLVED_STATUSES = ( "Closed", "Resolved", "Done", "Won't Fix", "Cancelled", ) def _split_csv(value: str | None) -> list[str] | None: if not value: return None return [item.strip() for item in value.split(",") if item.strip()] def load_status_sets( profile: str | None = None, issue_key: str | None = None, url: str | None = None, ) -> dict[str, frozenset[str]]: """Resolve qa / working / resolved status name sets. Profile resolution mirrors :func:`load_config` — explicit profile name wins, otherwise falls back to issue-key project prefix, URL host, the project-directory ``.jira-profile`` marker, or the default profile from ``profiles.json``. This keeps CLI behaviour consistent: the same profile that authenticates also supplies the workflow status sets. Priority per set: profile field → env var → built-in default. Profile fields: ``qa_status_names``, ``working_status_names``, ``resolved_status_names`` (each a JSON list). Env vars: ``JIRA_QA_STATUS_NAMES``, ``JIRA_WORKING_STATUS_NAMES``, ``JIRA_RESOLVED_STATUS_NAMES`` (comma-separated). """ prof: dict = {} if PROFILES_FILE.exists(): try: cwd = os.getcwd() except OSError: cwd = None try: prof = resolve_profile( profile=profile, issue_key=issue_key, url=url, project_dir=cwd, ) except (ValueError, FileNotFoundError): prof = {} def _resolve(profile_key: str, env_key: str, defaults: tuple[str, ...]) -> frozenset[str]: from_profile = prof.get(profile_key) if isinstance(from_profile, list) and from_profile: return frozenset(str(s) for s in from_profile) from_env = _split_csv(os.environ.get(env_key)) if from_env: return frozenset(from_env) return frozenset(defaults) return { "qa": _resolve("qa_status_names", "JIRA_QA_STATUS_NAMES", DEFAULT_QA_STATUSES), "working": _resolve("working_status_names", "JIRA_WORKING_STATUS_NAMES", DEFAULT_WORKING_STATUSES), "resolved": _resolve("resolved_status_names", "JIRA_RESOLVED_STATUS_NAMES", DEFAULT_RESOLVED_STATUSES), } # === INLINE_END: config === -
errors.py 1.7 KB
"""Typed Jira transport errors and error-message sanitization. Lives in its own module so that both ``lib.client`` (which raises these from its patched session) and ``lib.users`` (which must let them propagate instead of misreporting them as "unknown user") can import them without an import cycle. ``lib.client`` re-exports every name for existing importers. """ import re class CaptchaError(Exception): """Error raised when Jira requires CAPTCHA resolution. This happens when Jira Server/DC detects suspicious login activity and requires the user to complete a CAPTCHA challenge in the web UI. """ def __init__(self, message: str, login_url: str): super().__init__(message) self.login_url = login_url class AuthenticationError(Exception): """Raised when Jira returns 401 or 403 on an authenticated request. Provides a typed alternative to inspecting raw HTTP status codes or string-matching error messages for authentication failures. """ def _sanitize_error(message: str) -> str: """Remove potential credential fragments from error messages. Uses regex to redact values after sensitive keys, rather than a simple denylist check that discards the entire message. """ # Redact values following sensitive keys (e.g., "token=abc123" → "token=***") # First handle "Authorization: <scheme> <token>" as a single unit sanitized = re.sub( r"(authorization:\s*)\S+(?:\s+\S+)?", r"\1***", message, flags=re.IGNORECASE, ) sanitized = re.sub( r"(bearer |basic |token=|password=|api_token=|api_key=|secret=|access_token=|private_token=|apikey=|auth_token=)\S+", r"\1***", sanitized, flags=re.IGNORECASE, ) return sanitized -
input.py 3.4 KB
"""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.py 296 B
"""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('"', '\\"') -
markup.py 30.6 KB
"""Jira wiki-markup lint helpers. Catches the most damaging authoring mistakes before text is sent to Jira: - Block-markup tags ({code}, {noformat}, {quote}, {panel}) used inline. These are block-level macros; an unescaped tag with other text on the same line opens a real block mid-prose and swallows the rest of the line. Literal mentions must be escaped as \\{code\\}. - Unbalanced block tags (odd occurrence count), which leave an unclosed block that swallows everything after it. - Table data rows containing an unescaped ``||`` inside a cell. In a table ``|`` is the cell and ``||`` the header-cell delimiter, so a ``||`` in a normal ``|`` row (typically a Composer constraint like ``^12.4 || ^13.4``) splits the row and shifts every following column. An escaped ``\\|`` is a literal pipe and is left alone. Rewrite the cell without unescaped pipes. - Inline emphasis (``_italic_``, ``*bold*``) glued to the middle of a word. Jira only opens emphasis at a word boundary, so ``Konzept_qualität_`` is rendered with literal underscores rather than italics. Only the clearly broken shape is flagged (a marker preceded by a word char whose matching closer lands on a clause boundary - space, sentence punctuation, a closing bracket or quote, or line end - not before a connector like % or /); the body must be non-empty, so symmetric double-marker tokens (``__LINE__``, ``**bold**``) and snake_case identifiers such as ``be_acl`` or ``sys_file_reference`` are left alone. Content inside ``{{monospace}}`` and ``[links]`` is ignored. Known blind spot: a bare trailing-underscore prefix (``tx_news_``) is flagged like broken emphasis - wrap identifiers in ``{{monospace}}`` to silence it. - Dashes that Jira renders as a strikethrough span, outside code blocks. ``find_strikethrough_spans`` below implements the ``-text-`` grammar as measured against a live Jira Server 9.12 wiki renderer - not as a heuristic, and not as a guess, but as a deliberate SUPERSET of it: it may report a span the renderer would not draw, and aims never to miss one it would. Two classes of miss are known and pinned rather than fixed - autolinked issue keys, which are instance state (see ``find_strikethrough_spans``), and two macro-shaped tokens written against each other with nothing between them, which prose does not produce (see ``_mask_protected`` and the pinned list in ``tests/fixtures/strikethrough_corpus.json``). ``escape_strikethrough`` repairs the spans it does find, so callers can fix rather than bounce. Escaped tags (\\{code\\}), inline-monospace lookalikes ({{code}}) and *other* tags inside an open block are ignored. An occurrence of the *same* tag inside an open block closes it — exactly what the Jira renderer does (verified against Jira Server 9.12: a mid-line {code} inside a code block terminates the block there). """ import re import unicodedata BLOCK_TAGS = ("code", "noformat", "quote", "panel") # Unescaped block tag, optionally with parameters ({code:bash}, {panel:title=x}). # (?<!\{) keeps {{code}} / {{panel}} (inline monospace content) out of scope. _TAG_RE = re.compile(r"(?<!\\)(?<!\{)\{(code|noformat|quote|panel)(?::[^}\n]*)?\}") # Spans where markers are literal, blanked before the emphasis scan so their # content ({{sys_file_reference}}, [foo_bar_|url]) never trips the check. _INLINE_SPAN_RE = re.compile(r"\{\{.*?\}\}|\[[^\]\n]*\]") # The closer must sit before a real clause boundary: whitespace, sentence # punctuation, a closing bracket, a closing quote, or line end - NOT before a # connector like % $ / @, which would wrongly match a format-string or path # segment (%d_%m_%Y, some_dir_/f). Closing quotes are included because German # prose quotes UI/field labels; `/` and dashes stay excluded on purpose to keep # paths/format strings clean. The quote codepoints (ASCII " ', curly quotes, # guillemets) are built via chr() so this source stays ASCII-only. _CLOSE_QUOTES = "".join(map(chr, (0x22, 0x27, 0x2018, 0x2019, 0x201C, 0x201D, 0xAB, 0xBB))) _EMPH_CLOSE = "(?=[\\s.,;:!?)\\]}" + _CLOSE_QUOTES + "]|$)" # A marker preceded by a word char whose matching closer sits at a clause boundary # is broken-but-intended emphasis (Konzept_qualität_). The body is NON-EMPTY # ([^\s<m>]+): an empty body would make the trailing pair of every symmetric # double-marker token match, false-flagging PHP magic constants / dunders # (__LINE__, __CLASS__, __init__) and Markdown bold (**x**, __x__) - all common in # TYPO3/dev text. The body also cannot span another marker, so snake_case # identifiers (be_acl, sf_event_mgt), whose closer never lands on a boundary, do # not match. Known blind spot: a bare trailing-underscore prefix (tx_news_) is # structurally identical to broken emphasis and is still flagged - wrap identifiers # in {{monospace}} (good Jira practice) to silence it. _MIDWORD_EMPHASIS_RES = { "_": re.compile(r"(?<=\w)_[^\s_]+_" + _EMPH_CLOSE), "*": re.compile(r"(?<=\w)\*[^\s*]+\*" + _EMPH_CLOSE), } # ───────────────────────────────────────────────────────────────────────────── # Strikethrough (`-text-`) — the grammar, measured rather than guessed # # Every rule below is pinned by two fixtures recorded from the live Jira # Server 9.12 wiki renderer (POST /rest/api/1.0/render): # tests/fixtures/strikethrough_oracle.json (curated cases with their HTML) and # tests/fixtures/strikethrough_corpus.json (the generated bulk). Exact counts # live in those files rather than here, where re-recording would leave them # quietly false. # `scripts/verify-render-oracle.py --live` re-records the first and # `scripts/generate-strikethrough-corpus.py` the second, so "verified against # 9.12" is a command, not a sentence. The generator exists because the two # previous hand-derived versions of this rule were both wrong, and the second # was wrong while passing 168 hand-picked cases. # # The predecessor of this code was a single regex that flagged any # whitespace-preceded dash run followed by a word character. It was wrong in # both directions, which is why Jira kept mangling text that had passed the # lint while the lint kept bouncing text Jira renders fine: # # * FALSE POSITIVE, the common case: `journalctl -b -p crit` renders # literally. A dash that LEADS a word can never close a span (a closer # needs a non-space before it), so two flags cannot pair with each other. # They are NOT harmless in general: add a trailing-dash word later on the # same line and `-b ... zu-` is struck end to end. # * FALSE NEGATIVE, the damaging case: `{{nr-pforum}}-Extensions ... zu- und` # is struck through end to end (issue #226). Any inline element's closing # punctuation - `}}`, `*`, `_`, `]`, `!`, `{color}` - is a non-word # character and therefore a valid opener boundary, and a German elliptical # compound (`zu- und`) is a valid closer. # ───────────────────────────────────────────────────────────────────────────── def _is_escaped(line: str, pos: int) -> bool: """True when the character at ``pos`` is neutralised by a preceding backslash. Backslash parity is deliberately ignored: Jira treats the dash as literal in both ``\\-`` and ``\\\\-`` (the latter because ``\\\\`` is its forced-line-break macro), and over-reading an escape can only ever cost a redundant escape, never a missed span. """ return pos > 0 and line[pos - 1] == "\\" def _is_delimiter_space(ch: str) -> bool: """Whitespace for the purpose of the dash delimiters - ASCII only. ``str.isspace()`` is wrong here and wrong in the direction that mangles text: it counts U+00A0, and Jira does not. Measured, ``a -x\u00a0- y`` comes back struck through, so a non-breaking space before the closing dash does NOT disqualify it - while a plain space does (``a -x - y`` is clean). NBSP arrives routinely in text pasted out of Word or Outlook. This is NOT the same class as the ``\\s`` in ``_PROTECTED_RE``, and making the two agree would be a regression. Jira's URL autolinker DOES stop at U+00A0 (``https://h/a\u00a0/-/b`` links only ``https://h/a``), so ``\\s`` is correct there and ASCII-only is correct here. A review proposed unifying them; the renderer says they are two different rules. """ return ch in " \t\x0b\x0c\r" def _is_word_char(ch: str) -> bool: """Jira's word class for text effects: Unicode letters and digits only. Measured, because this is where a regex ``\\w`` would be wrong in both directions: ``Gr(oe)sse-x- y`` and ``a(ae)-x- y`` stay literal (a non-ASCII letter IS a word character), while ``a_-x- y`` opens a span (``_`` is NOT). """ return ch.isalnum() # Regions Jira resolves into a single element BEFORE text effects run, so a # dash inside them is never a delimiter. Measured: the `/-/` in a GitLab URL is # inert inside `[MR|https://host/g/p/-/merge_requests/5]` and in the same URL # written bare, and in `!-x.png!` - but the SAME `/-/` in plain prose # (`a /-/ zu- b`) does open a span. Escaping a dash inside a URL would break # the link, so these must be masked out before scanning. # # `{{monospace}}` is deliberately NOT in this list: text effects do apply # inside it (`{{-x-}}` comes back struck), which is the one place the old # implementation was right. # A bare URL runs to whitespace or to the next `{`, `}`, `]` or `|` - measured: # `https://h/a/-/b{{m}}` links only up to the brace, while `...b*b*` links the # lot. Stopping too late is the dangerous direction, because the mask then # swallows real markup and hides the dashes in it. # # A bare URL after a pipe or an exclamation mark is NOT autolinked - Jira expects that shape inside # [text|url], so standalone it stays literal and its dashes stay live # (`x a|https://h/a/-/b zu- y` and `x !https://h/a/-/b zu- y` both come back # struck, while `]`, `}` and `*` before the same URL leave it linked). # A backslash-escaped bracket # or bang is not a macro either, so its content is ordinary prose. # # A region only resolves at a boundary, and one that does NOT resolve leaves # its dashes live - masking it anyway would be a false negative, the direction # that mangles text. The boundary class is ASCII alphanumerics, measured: # `x ahttp://…/-/b- y` comes back struck (not autolinked, so `/-/` is prose), # while `x _http://…/-/b- y` and `x ähttp://…/-/b- y` are clean (autolinked). _PROTECTED_RE = re.compile( r""" (?<!\\) (?: \[[^\]\n]*\] # a square-bracketed link | (?<![A-Za-z0-9|!])(?:https?|ftp)://[^\s{}\]|]+ # bare URL - needs a boundary | (?<![A-Za-z0-9|!])mailto:[^\s{}\]|]+ | (?<![A-Za-z0-9])![^\s!]+! # image or attachment ) """, re.VERBOSE, ) _MASK = "\x01" # not a word character, so a masked region still ends a boundary # (\x01 rather than NUL: the awk mirror in validate-jira-syntax.sh cannot carry # a NUL through a string, and the two implementations are pinned to each other.) def _mask_protected(line: str) -> str: """Blank out protected regions, preserving length so indices stay valid. Two regions written back to back do not both resolve - Jira renders the first and leaves the second as literal text, dashes and all (`[t|https://h/a/-/b][t|https://h/a/-/b]` comes back with the second one struck through). So a match that begins exactly where the previous one ended is skipped rather than masked. Only the immediately following region is skipped, not a whole run: in `[a][b][c]` the third is masked again. Whether Jira resolves that third one is not measured. The awk mirror implements the same rule, and the whole-corpus parity test holds it to that on every recorded case - which is how a divergence here was caught once already: awk used to resume INSIDE the skipped region and mask a shorter one nested in it. The glued-region shapes this still gets wrong are pinned in the corpus fixture as known under-predictions. """ out = list(line) previous_end = -1 for match in _PROTECTED_RE.finditer(line): if match.start() == previous_end: continue out[match.start() : match.end()] = _MASK * (match.end() - match.start()) previous_end = match.end() return "".join(out) def find_strikethrough_spans(line: str) -> list[tuple[int, int]]: """Return ``(opener, closer)`` index pairs Jira renders struck through. One line at a time - a span never crosses a newline. The caller is responsible for skipping ``{code}``/``{noformat}`` content, where dashes are literal. The grammar, measured against Jira Server 9.12: * **Opener** - an unescaped ``-`` at line start or after a non-word character, followed by a character that is neither whitespace nor another dash. The boundary is why ``{{mono}}-Extensions`` opens a span and ``Round-1`` does not: ``}`` is a non-word character, ``d`` is not. * **Closer** - the first *valid* closer after it: an unescaped ``-`` not preceded by whitespace and followed by a non-word character or line end. A dash that fails those conditions is skipped, not fatal - which is why ``journalctl -b -p crit … zu-`` IS struck end to end while ``journalctl -b -p crit`` on its own is not: a dash that leads a word can never close, so flags alone have no closer at all. In a dash run the opener is the last dash and the closer the first, so ``a --foo-- b`` strikes ``foo`` and leaves the outer dashes literal. Whitespace here is ASCII only: Jira treats U+00A0 as an ordinary character, so a non-breaking space before the closing dash does not disqualify it. **This is a superset, on purpose.** Jira abandons an opener whose body begins with a single character followed by a dash (``a -a-b- z`` renders literally, while ``a -ab-cd- z`` is struck); that quirk is recorded in the fixture but not modelled, because the cost of predicting a span Jira would not draw is normally one redundant ``\\-``, which renders as a plain hyphen, whereas the cost of missing one is mangled text. The exception is the same glued-macro class the misses come from: where two macros are written against each other, the escape can land inside a URL, where it IS visible - `x !i.png![t|https://x.de/a/-/b]- y` comes back with `\\-` inside the link target. Three of the corpus's clean cases do this, all of that shape, and all three are in ``known_over_predictions``; prose that separates its macros with whitespace does not reach it. ``tests/test_strikethrough.py`` pins both directions: zero UNLISTED false negatives against the recorded corpus, and the list of known over-predictions, so neither can grow unnoticed. The counts live in the fixture, not here, where re-recording would leave them quietly false - as it already did once. **It is not exact, and cannot be.** Jira substitutes autolinked issue keys before text effects run, so ``OPS-899-x … zu-`` is struck on an instance where OPS-899 exists and clean on one where it does not - the same string, two renderings. No source-level model can decide that. The pre-flight render check in ``jira-comment.py`` is what covers it. """ spans: list[tuple[int, int]] = [] scan = _mask_protected(line) n = len(scan) # Openers are visited left to right and the scan resumes past a matched # closer, so one forward pointer into the precomputed list suffices. closers = _closer_positions(scan) next_closer = 0 i = 0 while i < n: if scan[i] != "-" or _is_escaped(scan, i): i += 1 continue # Opener: boundary before, and neither whitespace nor a dash after. if i > 0 and _is_word_char(scan[i - 1]): i += 1 continue if i + 1 >= n or _is_delimiter_space(scan[i + 1]) or scan[i + 1] == "-": i += 1 continue while next_closer < len(closers) and closers[next_closer] < i + 2: next_closer += 1 if next_closer >= len(closers): break # no closer can exist for this opener or any later one closer = closers[next_closer] spans.append((i, closer)) i = closer + 1 return spans def _opener_positions(scan: str) -> list[int]: """Every index in ``scan`` holding a dash that can open a span.""" n = len(scan) return [ i for i in range(n) if scan[i] == "-" and not _is_escaped(scan, i) and not (i > 0 and _is_word_char(scan[i - 1])) and i + 1 < n and not _is_delimiter_space(scan[i + 1]) and scan[i + 1] != "-" ] def _closer_positions(scan: str) -> list[int]: """Every index in ``scan`` holding a dash that can close a span. Computed once per line rather than searched per opener. Whether a dash can close depends only on its own two neighbours, never on where the span started, so a forward scan from each opener re-derives the same answer - which made the whole function quadratic in the number of dashes. A review measured 99 s on one 117 KB line; the regex this replaced was linear. """ n = len(scan) return [ j for j in range(1, n) if scan[j] == "-" and not _is_escaped(scan, j) and not _is_delimiter_space(scan[j - 1]) and not (j + 1 < n and _is_word_char(scan[j + 1])) ] def _escape_dash_run(line: str, opener: int) -> str: """Backslash-escape the whole dash run that ``opener`` belongs to. Escaping the opener alone is not enough, and the gap is easy to miss: in ``{{--strict}} foo bar-`` the opener is the SECOND dash, and neutralising just that one promotes the first to opener - the span survives, measured. Escaping the run closes it. ``\\-`` renders as a plain hyphen, so the repair is invisible to the reader. """ start = opener while start > 0 and line[start - 1] == "-" and not _is_escaped(line, start - 1): start -= 1 end = opener while end + 1 < len(line) and line[end + 1] == "-": end += 1 run = line[start : end + 1].replace("-", "\\-") return line[:start] + run + line[end + 1 :] # Only these two render their content verbatim. {quote} and {panel} still parse # text effects (measured: `{panel}\na {{m}}-x- b\n{panel}` comes back with a # <del>), so a dash inside them is exactly as dangerous as one in bare prose. _VERBATIM_TAGS = ("code", "noformat") _VERBATIM_RE = re.compile(r"^\s*(?<!\\)\{(" + "|".join(_VERBATIM_TAGS) + r")(?::[^}\n]*)?\}\s*$") def _split_verbatim(text: str): """Yield ``(line, is_verbatim)`` for every line, tracking {code}/{noformat}. A tag line is itself reported as verbatim: it is markup, not prose, and neither the dash scan nor the escaper has any business rewriting it. """ open_tag: str | None = None for line in text.split("\n"): m = _VERBATIM_RE.match(line) if open_tag is not None: yield line, True if m is not None and m.group(1) == open_tag: open_tag = None elif m is not None: open_tag = m.group(1) yield line, True else: yield line, False def escape_strikethrough(text: str) -> str: """Neutralise every dash Jira would read as a strikethrough opener. Returns text that renders identically to what the author meant: ``\\-`` is a plain hyphen on the page. Content inside ``{code}``/``{noformat}`` is left untouched, where a dash is literal already. Each pass escapes every opener that has a valid closer after it - a superset of the spans ``find_strikethrough_spans`` reports, since those are non-overlapping - right to left so the earlier offsets stay valid, and only then rescans. Repairing one span can expose the next - in ``a -x- -y- b`` the second pair becomes reachable once the first stops consuming its dashes - so a rescan is still needed, but a pass per span is not. Escaping one at a time made the cost quadratic in the number of spans on a line, which a review measured at 99 s for a single 117 KB line; the rule it replaced was linear, so that would have been a regression this change introduced. The loop stops if a pass fails to change the line. Every escape strictly reduces the number of unescaped dashes, so that cannot happen today - but this runs in the posting path, where a future edit that made the repair a no-op would otherwise hang the caller rather than fail it. (Not hypothetical: neutering ``_escape_dash_run`` in a mutation run hung the whole test suite.) A line the repair cannot converge on is left as it is and reported by ``lint_wiki_markup``, which callers run afterwards. """ out: list[str] = [] for line, verbatim in _split_verbatim(text): if not verbatim: while True: repaired = _escape_all_openers(line) if repaired == line: break line = repaired out.append(line) return "\n".join(out) def _escape_all_openers(line: str) -> str: """Escape every dash that could open a span, in one right-to-left pass. ``find_strikethrough_spans`` reports NON-OVERLAPPING spans, so repairing only what it returns needs one pass per nested opener: in ``-a0 -a1 ... -a9 zu-`` the first pass sees a single span from ``-a0`` to ``zu-``, and only after escaping it does ``-a1`` become one. That is a pass per dash, each rescanning and rebuilding the line - quadratic, where the rule this replaced was linear. A review measured 99 s on one 117 KB line. The fixed point is reachable directly: a span exists exactly where an opener has some valid closer at or after ``opener + 2``, and both sets are independent of each other, so escaping every such opener at once lands on the same result. Right to left, so the earlier offsets stay valid. The caller still loops, because escaping shifts positions and can in principle expose an opener that was not a candidate before; it converges on the second pass in practice, and the loop stops when a pass changes nothing. """ scan = _mask_protected(line) closers = _closer_positions(scan) if not closers: return line last_closer = closers[-1] openers = [i for i in _opener_positions(scan) if i + 2 <= last_closer] for opener in reversed(openers): line = _escape_dash_run(line, opener) return line def lint_wiki_markup(text: str) -> list[str]: """Return a list of human-readable lint findings (empty = clean).""" findings: list[str] = [] counts = dict.fromkeys(BLOCK_TAGS, 0) in_block: str | None = None # Verbatim tracking is separate from the block-balance state machine below: # that one guards all four tags, while only {code}/{noformat} suppress text # effects. Inside a {quote} or {panel} a dash still strikes through. verbatim_flags = [v for _, v in _split_verbatim(text)] for lineno, line in enumerate(text.split("\n"), 1): matches = list(_TAG_RE.finditer(line)) # Runs before the in_block short-circuit: {quote}/{panel} content is # skipped by that state machine but is NOT verbatim to Jira. if not verbatim_flags[lineno - 1]: for opener, closer in find_strikethrough_spans(line): findings.append( f"line {lineno}: {line[opener : closer + 1]!r} renders struck through - " f"Jira reads a dash after a non-word character as a strikethrough " f"opener (an inline element's closing {{{{}}}}, *, _, ] or ! counts) " f"and a dash before one as the closer; escape it as \\- (a " f"backslash-escaped dash still prints as a plain hyphen)" ) if in_block is not None: # Inside a block, only the matching closing tag is markup; # everything else on the line is verbatim content. closing = next((m for m in matches if m.group(1) == in_block), None) if closing is not None: counts[in_block] += 1 if line[closing.end() :].strip(): findings.append( f"line {lineno}: text after closing {{{in_block}}} tag - " f"block tags must stand alone on their own line" ) in_block = None continue # Table data row (starts with a single `|`) must not contain an # unescaped `||`: `||` is the header-cell delimiter and splits the row # mid-cell. An escaped `\|` is a literal pipe, so ignore it. stripped = line.strip() if stripped.startswith("|") and not stripped.startswith("||") and re.search(r"(?<!\\)(?:\\\\)*\|\|", stripped): findings.append( f"line {lineno}: table data row contains an unescaped '||' inside " f"a cell (e.g. a Composer constraint '^12.4 || ^13.4') - '|' is the " f"cell delimiter, so this splits the row; rewrite the cell without " f"unescaped pipes: {stripped[:80]!r}" ) # Inline emphasis glued mid-word renders as a literal marker. NFC-normalise # first so a word ending in a decomposed accent (NFD "e"+combining acute) # still presents a word char before the marker, then blank out {{monospace}} # and [link] spans, where the markers are literal. scan = _INLINE_SPAN_RE.sub(" ", unicodedata.normalize("NFC", line)) for marker, emphasis_re in _MIDWORD_EMPHASIS_RES.items(): if emphasis_re.search(scan): findings.append( f"line {lineno}: inline '{marker}' emphasis starts mid-word - " f"Jira only renders {marker}text{marker} at a word boundary, so " f"this shows the literal marker; emphasize the whole token at a " f"boundary (e.g. '{marker}Wort{marker}', not " f"'Prefix{marker}Wort{marker}'): {stripped[:80]!r}" ) if not matches: continue for m in matches: counts[m.group(1)] += 1 # A clean block-tag line contains nothing but a single tag; several # tags on one line ({code} {panel}) are inline usage even when the # remainder is whitespace. if _TAG_RE.sub("", line).strip() or len(matches) > 1: findings.append( f"line {lineno}: block tag used inline - {{code}}/{{noformat}}/" f"{{quote}}/{{panel}} are block markup and never inline; escape " f"literal mentions as \\{{code\\}}: {line.strip()[:80]!r}" ) elif len(matches) == 1: # Only a clean, solitary tag opens a block for lint purposes; # an inline tag is already flagged and would corrupt the state. in_block = matches[0].group(1) if in_block is not None: findings.append(f"unclosed {{{in_block}}} block - everything after the opening tag is swallowed") for tag, n in counts.items(): if n % 2: findings.append( f"unbalanced {{{tag}}} tags: {n} unescaped occurrence(s), expected " f"pairs - escape literal mentions as \\{{{tag}\\}}" ) return findings # Projects whose agent-authored content is English by convention. Team rules # live in the consuming skill (netresearch-jira, references/it/language.md); # this list only decides where the reminder fires. Matched on the key's # project part, `SRV`/`IO` as prefixes because of SRVGL, SRVC, IOS, IOT. _ENGLISH_ONLY_EXACT = frozenset({"NRS", "NRT", "LIC", "PO"}) _ENGLISH_ONLY_PREFIXES = ("SRV", "IO") # Function words that are common in German and rare-to-absent in English # technical prose. Deliberately excludes look-alikes that are ordinary English # words on their own (`die`, `war`, `hat`, `bald`, `also`, `fast`, `an`, `in`, # `so`, `man`), so an English sentence cannot accumulate hits by accident. _GERMAN_MARKERS = frozenset( """ aber auch auf aus bei beim bereits bis dabei damit dann dass dem den denn der des deshalb durch ein eine einem einen einer eines erst falls für gegen ist jede jeden jetzt kann kein keine mit nach nicht noch nur oder ohne schon sein seine sich sind soll sollte über und unter vom von vor wenn werden wird wurde wurden während zum zur zwei """.split() ) _WORD_RE = re.compile(r"[A-Za-zÄÖÜäöüß]+") # How many *distinct* markers must appear before the text is called German. # Five effectively require German sentence structure, so a loanword or a short # quoted fragment stays below it. The scan sees the whole body, quotes # included: a substantial German quote does reach five markers and does # produce a finding - that case is what --force is for. _GERMAN_MARKER_THRESHOLD = 5 def looks_german(text: str) -> tuple[bool, list[str]]: """Heuristic: does this text read as German prose? Returns (verdict, markers).""" words = {w.lower() for w in _WORD_RE.findall(text)} hits = sorted(words & _GERMAN_MARKERS) return len(hits) >= _GERMAN_MARKER_THRESHOLD, hits def is_english_only_project(issue_key: str) -> bool: """True when the key belongs to a project whose content is English by convention.""" project = issue_key.split("-", 1)[0].upper() return project in _ENGLISH_ONLY_EXACT or project.startswith(_ENGLISH_ONLY_PREFIXES) def lint_ticket_language(text: str, issue_key: str | None) -> list[str]: """Warn when German prose is about to be posted to an English-only project. The rule itself is a team convention (see the consuming team skill); what makes it worth a mechanical check is that prose alone has not held. Drift happens mid-session after a run of genuinely German tickets, and it is invisible in review because the ticket often already contains German from quoted mails. The scan reads the whole body, so a comment carrying a substantial German quote is reported like German prose - the check cannot tell a quote from authored text. That is the intended trade-off rather than a gap: posting quoted content verbatim is exactly what the caller's --force is for. """ if not issue_key or not is_english_only_project(issue_key): return [] german, hits = looks_german(text) if not german: return [] project = issue_key.split("-", 1)[0].upper() sample = ", ".join(hits[:6]) return [ f"text looks German ({sample}...) but {project} content is English by " f"convention - re-resolve the language per ticket; quoted user content " f"stays verbatim, so re-run with --force if that is what this is" ] -
markup_cli.py 10.8 KB
"""The three markup gates every wiki-markup write goes through, as CLI helpers. ``lib/markup.py`` holds the grammar and knows nothing about the terminal; ``lib/preview.py`` asks the instance. This module is the part that talks to the user: it repairs, it reports, and it decides when to abort. Same split as ``lib/users.py``, where ``check_mentions_cli`` wraps the mention lookup. It exists because these three lived in ``jira-comment.py`` and therefore ran on exactly two of the seven surfaces that post wiki markup. The other five - a worklog comment, the comment of ``jira-transition do`` and of ``jira-transition path``, and the description of ``jira-create issue`` and ``jira-issue update`` - render the same markup through the same renderer and mangled it the same way. Seven is counted from the places that POST a body. Counting the mention gate's call sites instead gives six and misses ``jira-transition path``, which carried neither gate. The order is fixed and matters: 1. ``repair_markup`` escapes what can be escaped. A strikethrough span is not a judgement call, it is a mechanical defect with a mechanical repair. 2. ``check_markup`` reports what cannot: block tags used inline, unbalanced tags, and anything the escaper left behind. 3. ``check_rendering`` asks the instance, which is the only way to see what no source-level model can - an autolinked issue key creates a boundary that exists only where that key resolves. """ import functools import sys from dataclasses import dataclass, replace import click from lib.markup import escape_strikethrough, lint_ticket_language, lint_wiki_markup from lib.output import error, warning from lib.preview import preflight_render class _Unset: """Sentinel for "not passed", so an explicit ``None`` stays distinguishable. ``render_issue_key=None`` means "render without issue context" and must not collapse into "fall back to ``issue_key``". """ _UNSET = _Unset() # What the three gates are called on the command line. Kept here so a caller # adding them does not invent a fourth spelling. FORCE_HELP = "Post despite wiki-markup lint findings or a struck-through render preview" NO_AUTO_ESCAPE_HELP = "Do not escape dashes Jira would render as strikethrough; add --force to actually post the span" NO_PREFLIGHT_HELP = "Skip the render-preview check against the Jira instance before posting" @dataclass(frozen=True) class MarkupGates: """What the three flags decided, as one value. They are never meaningful apart - ``--no-auto-escape`` alone does not post a deliberate strikethrough, because the lint and the render check each refuse the surviving span - so they travel as one. The same reason ``guard_wiki_markup`` is a single call rather than three. """ force: bool auto_escape: bool preflight: bool def offline(self) -> "MarkupGates": """The same decision with the render call dropped, for a ``--dry-run``. The escape and the lint still run: a preview must show the text a real write would post. Only the part that talks to the instance goes. """ return replace(self, preflight=False) def markup_options(func): """Add the three flags to a command and hand them over as one ``gates``. A command that writes wiki markup takes ``gates: MarkupGates`` instead of three booleans it would only ever pass on together. Two of three cannot be wired by accident, and a command does not grow three parameters per gate - ``jira-issue update`` crossed a 13-parameter limit when they were separate. """ @functools.wraps(func) def wrapper(*args, force, no_auto_escape, no_preflight, **kwargs): kwargs["gates"] = MarkupGates(force=force, auto_escape=not no_auto_escape, preflight=not no_preflight) return func(*args, **kwargs) # functools.wraps copies __dict__, which ALIASES __click_params__ rather # than copying it - the three options below would be appended to the inner # function's list too. Harmless while each decorated function backs exactly # one command, and a silent way to give a second command these flags if one # ever did not. wrapper.__click_params__ = list(getattr(func, "__click_params__", [])) # Applied bottom-up, so listing them in reverse keeps --force first in # --help. Asserted by test_every_surface_offers_the_three_flags. for option in ( click.option("--no-preflight", is_flag=True, help=NO_PREFLIGHT_HELP), click.option("--no-auto-escape", is_flag=True, help=NO_AUTO_ESCAPE_HELP), click.option("--force", is_flag=True, help=FORCE_HELP), ): wrapper = option(wrapper) return wrapper def repair_markup(text: str, auto_escape: bool, *, label: str = "text") -> str: """Escape dashes Jira would render as strikethrough, and say what changed. ``\\-`` prints as a plain hyphen, so the posted text reads exactly as written. Reporting the repair on stderr is deliberate - a silent rewrite of the user's text would be worse than the bug. ``--no-auto-escape`` turns it off, but not on its own: ``check_markup`` and ``check_rendering`` each refuse the surviving span independently, so a deliberate strikethrough needs ``--no-auto-escape --force``. """ if not auto_escape: return text repaired = escape_strikethrough(text) if repaired == text: return text changed = [ (n, after) for n, (before, after) in enumerate(zip(text.split("\n"), repaired.split("\n"), strict=True), 1) if before != after ] warning( f"auto-escaped {len(changed)} line(s) of the {label} that Jira would have rendered struck " f"through (\\- prints as a plain hyphen; use --no-auto-escape to keep the markup as written)" ) for n, after in changed[:5]: warning(f" line {n}: {after.strip()[:100]!r}") return repaired def check_markup(text: str, force: bool, issue_key: str | None = None, *, label: str = "text") -> None: """Lint wiki markup and ticket language; abort on findings unless forced. The two kinds are labelled and explained separately: a language-only finding reported as a markup problem, with a suggestion about block tags, sends the reader looking for the wrong defect. """ markup_findings = lint_wiki_markup(text) language_findings = lint_ticket_language(text, issue_key) if not markup_findings and not language_findings: return if force: for finding in markup_findings: warning(f"markup: {finding}") for finding in language_findings: warning(f"language: {finding}") return labelled = [f"markup: {f}" for f in markup_findings] + [f"language: {f}" for f in language_findings] hints = [] if markup_findings: hints.append("Block tags are never inline; escape literal mentions as \\{code\\}.") if language_findings: hints.append("Re-resolve the language for this ticket; quoted user content stays verbatim.") hints.append("Re-run with --force to post anyway.") error(f"Lint found problems in the {label}:\n " + "\n ".join(labelled), suggestion=" ".join(hints)) sys.exit(1) def check_rendering( text: str, force: bool, issue_key: str | None, enabled: bool, env_file: str | None = None, profile: str | None = None, *, label: str = "text", ) -> None: """Ask the instance how it will render this text, and refuse a mangled write. The lexical repair handles what a model of the grammar CAN handle. This handles what it cannot, and the gap is not academic: Jira substitutes autolinked issue keys before text effects run, so ``OPS-899-x und zu- und`` comes back with ``x und zu`` struck through on an instance where OPS-899 exists and clean on one where it does not: the opener is positioned relative to the substituted link, not the source text. A resolved issue's key drawn in ``<del>`` inside its own link is status styling, and ``preflight_render`` does not report it. Advisory by construction. An unreachable, slow or absent renderer (the endpoint is Server/DC only) prints one warning and gets out of the way; it must never stop somebody writing to Jira. """ if not enabled: return verdict = preflight_render(text, issue_key=issue_key, env_file=env_file, profile=profile) if not verdict.available: warning(f"render preview unavailable ({verdict.reason}) - relying on the local markup lint alone") return if not verdict.struck: return struck = "; ".join(repr(s[:80]) for s in verdict.struck[:3]) if force: warning(f"rendering: Jira strikes through {struck}") return error( f"Jira renders part of this {label} struck through: {struck}", suggestion=( "The local escape could not fix it - this usually means an autolinked issue key " "(a dash right after PROJ-123) or another macro creating the boundary. Rephrase, " "put the token in a {code} block, or re-run with --force to write it anyway." ), ) sys.exit(1) def guard_wiki_markup( text: str, *, gates: MarkupGates, issue_key: str | None = None, render_issue_key: str | None | _Unset = _UNSET, env_file: str | None = None, profile: str | None = None, label: str = "text", ) -> str: """Run all three gates in order and return the text to write. One call so a caller cannot wire up two of the three and believe it is covered - which is how the description paths went unguarded while the comment path had the full treatment. ``render_issue_key`` exists for ``jira-create issue``, where the two gates need different things. The language lint only reads the project part of a key, so the project key alone answers it. The render endpoint resolves the key to an actual issue and answers 404 for a project - measured against jira.netresearch.de - which would print "render preview unavailable" on every single create. Passing ``None`` renders without issue context, which the endpoint accepts: the text is checked, only the autolink substitution that needs a surrounding issue is not. An absent or empty body passes straight through: ``--comment`` and ``--description`` are optional on most of these commands, and "nothing to post" is not a markup problem. Returning it unchanged also keeps None a None, so the caller's own "did the user supply one?" check still works. """ if not text or not text.strip(): return text text = repair_markup(text, gates.auto_escape, label=label) check_markup(text, gates.force, issue_key, label=label) key_for_render = issue_key if render_issue_key is _UNSET else render_issue_key check_rendering(text, gates.force, key_for_render, gates.preflight, env_file, profile, label=label) return text -
output.py 6.9 KB
"""Output formatting utilities for Jira CLI scripts.""" import json import sys from typing import Any # === INLINE_START: output === def _ensure_utf8_streams() -> None: """Reconfigure stdout/stderr to UTF-8 on Windows. Windows consoles default to a locale-specific encoding (e.g. cp1252) that cannot represent Unicode symbols used in status messages (✓, ✗, ⚠). This causes 'charmap' codec errors *after* a Jira API call has already succeeded, making the script exit non-zero and tempting callers to retry — which creates duplicate issues/comments. Called once at module import so every script that imports output.py benefits automatically. """ if sys.platform == "win32": for stream_name in ("stdout", "stderr"): stream = getattr(sys, stream_name) if hasattr(stream, "reconfigure"): stream.reconfigure(encoding="utf-8", errors="replace") _ensure_utf8_streams() def format_json(data: Any, indent: int = 2) -> str: """Format data as JSON string. Args: data: Data to format indent: Indentation level Returns: JSON formatted string """ return json.dumps(data, indent=indent, default=str) def format_table(data: list, columns: list | None = None) -> str: """Format list of dicts as ASCII table. Args: data: List of dictionaries columns: Optional list of column names to include Returns: ASCII table string """ if not data: return "(no data)" # Determine columns if columns is None: columns = list(data[0].keys()) if isinstance(data[0], dict) else ["value"] # Calculate column widths widths = {col: len(col) for col in columns} for row in data: if isinstance(row, dict): for col in columns: val = str(row.get(col, "")) widths[col] = max(widths[col], len(val)) # Build table lines = [] # Header header = " | ".join(col.ljust(widths[col]) for col in columns) lines.append(header) lines.append("-+-".join("-" * widths[col] for col in columns)) # Rows for row in data: if isinstance(row, dict): line = " | ".join(str(row.get(col, "")).ljust(widths[col]) for col in columns) else: line = str(row) lines.append(line) return "\n".join(lines) def compact_json(data: Any) -> Any: """Recursively drop keys whose value is None or an empty list. Motivation: Jira issue payloads include 100+ `customfield_*` entries that are typically null on any given issue, bloating `--json` output to 50+ KB for a single issue. This helper removes the noise without touching meaningful falsy values (0, False, ""). Rules: * dict values that are `None` or `[]` are dropped * lists are mapped element-wise * everything else (including 0, False, "") passes through * the input is not mutated Args: data: Any JSON-like value (dict, list, scalar). Returns: A new structure with null/empty-list entries removed. """ if isinstance(data, dict): return { k: compact_json(v) for k, v in data.items() if v is not None and not (isinstance(v, list) and len(v) == 0) } if isinstance(data, list): return [compact_json(item) for item in data] return data def format_output(data: Any, as_json: bool = False, quiet: bool = False) -> None: """Format and print output based on flags. Args: data: Data to output as_json: Output as JSON if True quiet: Minimal output if True """ if quiet: if isinstance(data, dict) and "key" in data: print(data["key"]) elif isinstance(data, list) and data and isinstance(data[0], dict) and "key" in data[0]: for item in data: print(item.get("key", "")) else: print(data if isinstance(data, str) else format_json(data)) return if as_json: print(format_json(data)) return # Human-readable format if isinstance(data, dict): _print_dict(data) elif isinstance(data, list): if data and isinstance(data[0], dict): print(format_table(data)) else: for item in data: print(item) else: print(data) def _print_dict(data: dict, indent: int = 0) -> None: """Pretty print a dictionary.""" prefix = " " * indent for key, value in data.items(): if isinstance(value, dict): print(f"{prefix}{key}:") _print_dict(value, indent + 1) elif isinstance(value, list): print(f"{prefix}{key}: {', '.join(str(v) for v in value[:5])}") if len(value) > 5: print(f"{prefix} ... and {len(value) - 5} more") else: print(f"{prefix}{key}: {value}") def error(message: str, suggestion: str | None = None) -> None: """Print error message with optional suggestion. Args: message: Error message suggestion: Optional suggestion for resolution """ print(f"✗ {message}", file=sys.stderr) if suggestion: print(f"\n {suggestion}", file=sys.stderr) def success(message: str) -> None: """Print success message.""" print(f"✓ {message}") def warning(message: str) -> None: """Print warning message.""" print(f"⚠ {message}", file=sys.stderr) def extract_adf_text(adf) -> str: """Extract plain text from Atlassian Document Format. Recursively traverses all ADF node types (paragraphs, headings, lists, code blocks, blockquotes, tables, etc.) to extract text content. Args: adf: ADF dictionary or any other value Returns: Extracted plain text string """ if not isinstance(adf, dict): return str(adf) parts = _extract_text_recursive(adf) return " ".join(parts) def _extract_text_recursive(node) -> list: """Recursively extract text from any ADF node.""" parts = [] if isinstance(node, dict): if node.get("type") == "text": text = node.get("text", "") if text: parts.append(text) for child in node.get("content", []): parts.extend(_extract_text_recursive(child)) return parts def comment_to_text(comment) -> str: """Normalize a Jira comment field to plain text. Comments come in three shapes: - None / missing on the issue → empty string - Server/DC: plain string (already plain text) - Cloud: ADF dictionary → extracted via extract_adf_text() Used by callers that render worklog/issue comments for terminal output, where a raw ADF dict would print as ``{'type': 'doc', ...}``. """ if comment is None: return "" if isinstance(comment, dict): return extract_adf_text(comment) or "" return str(comment) # === INLINE_END: output === -
preview.py 9.9 KB
"""Ask the Jira instance how it will render a piece of wiki markup. The lexical model in ``lib.markup`` predicts what Jira does to dashes, and it is measured rather than guessed - but it is a model, and one class of defect is out of its reach by construction: Jira substitutes autolinked issue keys before text effects run. ``OPS-899-x ... zu-`` is struck through on an instance where OPS-899 exists and renders literally on one where it does not. Same string, two renderings, decided by state that lives in the Jira database. No source-level model can decide it, and a better regex will not change that - which is why the same class of bug kept coming back. One ``<del>`` in the answer is not a text effect at all: Jira draws a link to a RESOLVED issue with the key struck through inside its anchor, as status styling. That shape is unwrapped before the spans are collected, so mentioning a resolved issue is not a finding while a real span next to or around the link still is. This module asks instead of predicting. ``POST /rest/api/1.0/render`` is the endpoint behind Jira's own "preview" button on Server/DC, so the answer is the renderer's, not ours. It is advisory by design: a renderer that is unreachable, slow, or absent (the endpoint is Server/DC-only) must never stop somebody posting a comment. Every failure path returns "unknown" and says why, and the caller degrades to the lexical check. """ import html import re from dataclasses import dataclass from typing import Any import requests from lib.config import is_cloud_url, load_config RENDER_PATH = "/rest/api/1.0/render" DEFAULT_TIMEOUT = 10 _DEL_RE = re.compile(r"<del>(.*?)</del>", re.S) # A tag, with quoted attribute values taken whole: an issue link's title is the # issue summary, and a literal `>` in it must not end the tag. Scope is the # renderer's own output, which quotes every attribute: a tag with a stray, # unpaired quote does not match and stays in the text. For the unwrap that # means the marker is kept and reported, the safe direction. _ATTRS = r"""(?:[^>"']|"[^"]*"|'[^']*')*""" _TAG_RE = re.compile(rf"<{_ATTRS}>") # Jira draws a link to a RESOLVED issue with its key in <del>, inside the # anchor: `<a class="issue-link" data-issue-key="K"><del>K</del></a>`. That is # issue-status styling, not text-effect markup; escaping cannot change it, and # reporting it refused every comment that mentioned a resolved issue. A genuine # span is always outside the anchor (around it or next to it), so only the # exact shape is unwrapped: an issue-link anchor whose whole content is # `<del>` + its own data-issue-key + `</del>`. _RESOLVED_ISSUE_LINK_RE = re.compile(rf"(<a\b{_ATTRS}>)<del>([^<]*)</del></a>") # Attribute values by exact attribute name: `(?<![\w-])` keeps `data-class=` # from reading as `class=`. The class list is then split into tokens rather # than matched by a pattern, so `my-issue-link` is not `issue-link`. _CLASS_ATTR_RE = re.compile(r"""(?<![\w-])class=(["'])(.*?)\1""") _DATA_ISSUE_KEY_RE = re.compile(r"""(?<![\w-])data-issue-key=(["'])(.*?)\1""") # Jira macro syntax, stripped before looking for echoed prose: {code}, {color:red}, # {{monospace}}, [text|url], !image.png!. _MACRO_RE = re.compile(r"\{\{.*?\}\}|\{[^}\n]*\}|\[[^\]\n]*\]|![^\s!]+!") def _unwrap_resolved_issue_links(body: str) -> str: """Drop the resolved-issue ``<del>`` so only text-effect spans remain.""" def unwrap(match: re.Match) -> str: anchor, text = match.group(1), match.group(2) classes = _CLASS_ATTR_RE.search(anchor) key = _DATA_ISSUE_KEY_RE.search(anchor) is_issue_link = classes is not None and "issue-link" in classes.group(2).split() if is_issue_link and key and key.group(2) == text: return f"{anchor}{text}</a>" return match.group(0) return _RESOLVED_ISSUE_LINK_RE.sub(unwrap, body) @dataclass class RenderVerdict: """What the instance said, or why it could not be asked. ``available`` is False for every failure - unreachable, unauthorised, Cloud, endpoint missing. It is deliberately NOT collapsed into ``struck=False``: "the renderer says this is clean" and "nobody asked the renderer" must stay distinguishable, or a network blip reads as a clean bill of health. """ available: bool struck: list[str] reason: str = "" @property def ok(self) -> bool: return self.available and not self.struck def _plain(fragment: str) -> str: return _TAG_RE.sub("", fragment).strip() def _looks_like_render_output(body: str, source: str) -> bool: """Cheap sanity check that ``body`` is the renderer's answer to ``source``. Two signals, both one-directional. The renderer returns a fragment, so a full HTML document is somebody else answering - a login page, a proxy, a maintenance notice. And the renderer echoes the text it was given, so a reasonably long word from the input should survive into the output. Deliberately lenient: a false "unavailable" costs one warning, while a false "available" is a clean verdict on an unrendered comment. """ lowered = body.lower() if "<html" in lowered or "<!doctype" in lowered: return False # Words are taken from the PROSE, with macro syntax removed first. A macro # name is not echoed - the renderer consumes it - so `{color:red}ok{color}` # or `!screenshot.png!` would otherwise be rejected as "not render output", # and an image-only comment is an ordinary Jira comment. prose = _MACRO_RE.sub(" ", source) words = re.findall(r"[A-Za-z\u00c0-\u024f]{4,}", prose) if not words: return True # nothing to look for; the document check above stands alone # ANY surviving word, not all of them and not the longest: the renderer # entity-escapes non-ASCII (`Übersicht` comes back as `Übersicht`), so # requiring a specific word would fail on ordinary German prose. plain = html.unescape(_plain(body)).lower() return any(word.lower() in plain for word in words) def _resolve_config(issue_key: str | None, env_file: str | None, profile: str | None) -> tuple[dict, str]: """Config for the SAME instance the write will go to, or the reason there is none. This calls ``load_config`` - the loader ``LazyJiraClient`` itself uses - rather than reimplementing part of it. An earlier version only consulted profiles when ``--profile`` was given explicitly, which missed the common case entirely: ``jira-comment.py add OPS-899 "..."`` resolves a profile BY ISSUE KEY in the client and fell back to ``~/.env.jira`` here. A preview rendered against a different Jira is worse than no preview - the key does not resolve there, the autolink substitution never fires, and the verdict comes back clean on exactly the case this module exists to catch. """ try: return load_config(profile=profile, env_file=env_file, issue_key=issue_key), "" except (ValueError, KeyError, OSError, FileNotFoundError) as exc: # Carry the reason: "profiles.json is unreadable" and "JIRA_URL is not # set" are different problems and the message is what gets acted on. return {}, f"could not resolve the Jira config: {type(exc).__name__}: {exc}" def preflight_render( text: str, *, issue_key: str | None = None, env_file: str | None = None, profile: str | None = None, timeout: int = DEFAULT_TIMEOUT, session: Any = None, ) -> RenderVerdict: """Render ``text`` on the configured instance and report struck-through spans. Returns the plain text of every ``<del>`` the renderer produced. An empty list with ``available=True`` means the instance itself says the markup is clean - the strongest statement available short of posting it. """ config, problem = _resolve_config(issue_key, env_file, profile) if problem: return RenderVerdict(False, [], problem) base_url = config.get("JIRA_URL") if not base_url: return RenderVerdict(False, [], "JIRA_URL is not configured") if is_cloud_url(base_url): # Cloud has no api/1.0 wiki renderer and uses ADF anyway. Saying so # beats a 404 the caller has to interpret. return RenderVerdict(False, [], "preview endpoint is Server/DC only") http = session or requests.Session() if session is None: if config.get("JIRA_PERSONAL_TOKEN"): http.headers["Authorization"] = f"Bearer {config['JIRA_PERSONAL_TOKEN']}" elif config.get("JIRA_USERNAME") and config.get("JIRA_API_TOKEN"): http.auth = (config["JIRA_USERNAME"], config["JIRA_API_TOKEN"]) else: return RenderVerdict(False, [], "no Jira credentials found") try: response = http.post( f"{base_url.rstrip('/')}{RENDER_PATH}", json={ "rendererType": "atlassian-wiki-renderer", "unrenderedMarkup": text, # Passing the key is the faithful context even though this # instance renders identically without it (measured on # OPS-899); a future Jira may not. "issueKey": issue_key, }, headers={"Content-Type": "application/json"}, timeout=timeout, ) except requests.RequestException as exc: return RenderVerdict(False, [], f"render request failed: {type(exc).__name__}") if response.status_code != 200: return RenderVerdict(False, [], f"render endpoint returned HTTP {response.status_code}") if not _looks_like_render_output(response.text, text): # An SSO or maintenance page intercepting the request answers 200 with # HTML that contains no <del>, which would otherwise be reported as # "the instance says this is clean" - the one reading this module must # never produce. return RenderVerdict(False, [], "response does not look like render output") body = _unwrap_resolved_issue_links(response.text) return RenderVerdict(True, [_plain(m) for m in _DEL_RE.findall(body)]) -
render.py 1.8 KB
"""Terminal rendering of issue descriptions and comments. Shared by ``jira-issue.py`` (work / qa / qa-fail) and ``jira-qa-gather.py`` so both print the same shape: the description indented under a ``Description:`` header, each comment under a ``--- [created] author ---`` separator. Lives outside ``output.py`` because it needs ``users.person_label`` and ``users`` already imports ``output`` (no circular imports between lib modules). """ from .output import comment_to_text, extract_adf_text from .users import person_label def truncate_text(text: str, n: int | None) -> str: """Cut ``text`` to at most ``n`` chars at a word boundary; ``n`` falsy = no-op.""" if not n or len(text) <= n: return text return text[:n].rsplit(" ", 1)[0] + " …[truncated]" def print_comment(comment: dict, *, truncate: int | None = None) -> None: """Print one comment: ``--- [YYYY-MM-DD HH:MM] Author (name) ---`` then the body.""" author = person_label(comment.get("author")) created = comment.get("created", "")[:16].replace("T", " ") body = comment_to_text(comment.get("body")) if truncate: body = truncate_text(body, truncate) print(f"\n--- [{created}] {author} ---") for line in body.split("\n"): print(line) def print_description(issue: dict, *, truncate: int | None = None) -> None: """Print the issue description (Server string or Cloud ADF) under a header; no-op when empty.""" description = issue.get("fields", {}).get("description") if not description: return if isinstance(description, dict): description = extract_adf_text(description) text = str(description) if truncate: text = truncate_text(text, truncate) print("\nDescription:") for line in text.split("\n"): print(f" {line}") -
users.py 7.2 KB
"""User lookup and [~username] mention verification helpers. Mentions posted with an unverified username render as dead text in Jira — the user is never notified. These helpers let write commands confirm every mention inside the same CLI invocation (no separate lookup call needed) and let read commands print technical usernames next to display names so agents can mention ticket participants without any lookup at all. """ import re import sys from .config import is_cloud_url from .errors import AuthenticationError, CaptchaError, _sanitize_error from .output import error # [~username] wiki-markup mention. Cloud emits [~accountid:<id>] instead. # A leading backslash escapes the mention into literal text — skip those. MENTION_PATTERN = re.compile(r"(?<!\\)\[~([^\]\s]+)\]") _ACCOUNTID_PREFIX = "accountid:" # {code}/{noformat} spans render their content literally, so a mention inside # them never notifies anyone — quoting a log line must not trip the gate. _LITERAL_BLOCK_RE = re.compile(r"\{(code|noformat)(?::[^}\n]*)?\}.*?\{\1\}", re.DOTALL | re.IGNORECASE) def is_cloud_client(client) -> bool: """Mock-friendly Cloud detection via the URL (repo convention — the ``cloud`` attribute is easy to omit in mocks, see LazyJiraClient.jql).""" url = getattr(client, "url", "") return is_cloud_url(url) if isinstance(url, str) else False def extract_mentions(text: str) -> list[str]: """Return unique [~...] mention identifiers in order of first appearance. Skips mentions inside {code}/{noformat} blocks (rendered literally) and backslash-escaped literals (``\\[~...]``). """ stripped = _LITERAL_BLOCK_RE.sub("", text or "") seen: list[str] = [] for match in MENTION_PATTERN.finditer(stripped): ident = match.group(1) if ident not in seen: seen.append(ident) return seen def person_label(user: dict | None, fallback: str = "Unknown") -> str: """Render a user dict as 'Display Name (username)'. The parenthesized identifier is what mentions and --assignee need: the technical username on Server/DC, ``accountid:<id>`` on Cloud (whose user dicts carry no ``name``). """ if not user: return fallback display = user.get("displayName") or user.get("name") or fallback name = user.get("name") if name and name != display: return f"{display} ({name})" if not name and user.get("accountId"): return f"{display} (accountid:{user['accountId']})" return display def mention_token(user: dict) -> str | None: """The [~...] markup that actually notifies this user, or None.""" name = user.get("name") or user.get("key") if name: return f"[~{name}]" account_id = user.get("accountId") if account_id: return f"[~{_ACCOUNTID_PREFIX}{account_id}]" return None def find_users(client, query: str, limit: int = 10) -> list[dict]: """Search users by name/username/email fragment. Returns raw user dicts. atlassian-python-api v3 routes the fragment via ``query=`` only on Cloud; Server/DC requires ``username=`` (with ``query=`` the library returns an error string there, never results). """ if is_cloud_client(client): users = client.user_find_by_user_string(query=query, limit=limit) else: users = client.user_find_by_user_string(username=query, limit=limit) if isinstance(users, list): return [u for u in users if isinstance(u, dict)] return [] def _infrastructure_error(exc: Exception) -> bool: """True for failures that mean 'the lookup could not run', never 'no such user': auth/CAPTCHA challenges and any HTTP status other than 404.""" if isinstance(exc, (AuthenticationError, CaptchaError)): return True status = getattr(getattr(exc, "response", None), "status_code", None) return status is not None and status != 404 def verify_mentions(client, text: str) -> dict[str, list[dict]]: """Verify every [~username] mention in ``text`` against Jira. Returns a dict of unknown mention identifiers mapped to suggestion user dicts (may be empty). An empty return dict means every mention resolved. [~accountid:...] mentions are machine-generated identifiers and are skipped rather than guessed at. On Cloud a plain [~username] mention can never notify (Cloud requires the accountid form), so it is always reported, with [~accountid:...] suggestions. Auth/transport failures propagate — they are not evidence that a user does not exist. """ unknown: dict[str, list[dict]] = {} cloud = is_cloud_client(client) for ident in extract_mentions(text): if ident.startswith(_ACCOUNTID_PREFIX): continue if not cloud: try: user = client.user(username=ident) if isinstance(user, dict) and (user.get("name") or user.get("key")): continue except Exception as exc: if _infrastructure_error(exc): raise # 404 on exact lookup — the mention is unknown; fall through # to the suggestion search below. try: suggestions = find_users(client, ident, limit=5) except Exception as exc: if _infrastructure_error(exc): raise # Suggestions are best-effort; report the mention as unknown # without candidates rather than failing the whole check. suggestions = [] unknown[ident] = suggestions return unknown def format_unknown_mentions(unknown: dict[str, list[dict]]) -> str: """Human-readable lines for an ``verify_mentions`` result, suggestion tokens rendered in the form that actually notifies ([~name] on Server/DC, [~accountid:...] on Cloud).""" lines = [] for ident, suggestions in unknown.items(): line = f"[~{ident}] does not match any notifiable Jira user" candidates = [ f"{token} ({user.get('displayName', '?')})" for user in suggestions if (token := mention_token(user)) ] if candidates: line += " — did you mean: " + ", ".join(candidates) lines.append(line) return "\n ".join(lines) def check_mentions_cli(client, text: str | None, skip: bool = False) -> None: """Shared CLI gate for every command that posts wiki-markup with mentions. No-op when ``skip`` is set or the text carries no ``[~`` (zero API calls). Exits 1 with suggestions on unknown mentions, and with the sanitized real error on auth/transport failures — never misreporting those as an unknown username. """ if skip or not text or "[~" not in text: return try: unknown = verify_mentions(client, text) except Exception as exc: error( f"Mention verification failed ({_sanitize_error(str(exc))}) — a transport/auth problem, not an unknown username", suggestion="Fix credentials/connectivity, or re-run with --no-verify-mentions to post without the check.", ) sys.exit(1) if not unknown: return error( "Unverified mention(s):\n " + format_unknown_mentions(unknown), suggestion="Use an exact identifier from the suggestions, or re-run with --no-verify-mentions to post as-is.", ) sys.exit(1) -
__init__.py 663 B
"""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", ]
-
-
utility
-
jira-fields.py 7.2 KB
#!/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 field operations - search and list fields.""" 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, get_project_issue_types 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 (field IDs 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 field operations. Search and list Jira fields (including custom fields). """ 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) @cli.command() @click.argument("keyword") @click.option("--limit", "-n", default=20, help="Max results to show") @click.pass_context def search(ctx, keyword: str, limit: int): """Search fields by keyword. KEYWORD: Search term (matches name or ID) Useful for finding custom field IDs for --fields-json options. Examples: jira-fields search sprint jira-fields search "story points" jira-fields search customfield """ client = ctx.obj["client"] try: # Get all fields fields = client.get_all_fields() # Filter by keyword (case-insensitive) keyword_lower = keyword.lower() matching = [ f for f in fields if keyword_lower in f.get("name", "").lower() or keyword_lower in f.get("id", "").lower() ][:limit] if ctx.obj["json"]: format_output(matching, as_json=True) elif ctx.obj["quiet"]: for f in matching: print(f.get("id", "")) else: if not matching: print(f"No fields matching '{keyword}'") else: print(f"Fields matching '{keyword}' ({len(matching)} shown):\n") rows = [] for f in matching: rows.append( { "ID": f.get("id", ""), "Name": f.get("name", ""), "Type": f.get("schema", {}).get("type", "-"), "Custom": "Yes" if f.get("custom", False) else "No", } ) print(format_table(rows, ["ID", "Name", "Type", "Custom"])) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to search fields: {e}") sys.exit(1) @cli.command("list") @click.option( "--type", "-t", "field_type", type=click.Choice(["custom", "system", "all"]), default="all", help="Filter by field type", ) @click.option("--limit", "-n", default=50, help="Max results to show") @click.pass_context def list_fields(ctx, field_type: str, limit: int): """List available fields. Examples: jira-fields list jira-fields list --type custom jira-fields list --type system --limit 100 """ client = ctx.obj["client"] try: fields = client.get_all_fields() # Filter by type if field_type == "custom": fields = [f for f in fields if f.get("custom", False)] elif field_type == "system": fields = [f for f in fields if not f.get("custom", False)] fields = fields[:limit] if ctx.obj["json"]: format_output(fields, as_json=True) elif ctx.obj["quiet"]: for f in fields: print(f.get("id", "")) else: type_label = field_type if field_type != "all" else "all" print(f"Jira fields ({type_label}, {len(fields)} shown):\n") rows = [] for f in fields: rows.append( { "ID": f.get("id", ""), "Name": f.get("name", ""), "Custom": "Yes" if f.get("custom", False) else "No", } ) print(format_table(rows, ["ID", "Name", "Custom"])) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to list fields: {e}") sys.exit(1) @cli.command("types") @click.argument("project", required=False) @click.pass_context def list_types(ctx, project: str | None): """List available issue types. If PROJECT is given, show types for that project (including subtask flag). Otherwise, show all global issue types. Examples: jira-fields.py types PROJ jira-fields.py types """ client = ctx.obj["client"] try: if project: # Use project key for profile resolution client.with_context(issue_key=f"{project}-1") types = get_project_issue_types(client, project) else: types = client.get_all_issuetypes() if ctx.obj["json"]: format_output(types, as_json=True) return if not types: warning("No issue types found") return rows = [] for t in types: rows.append( { "Name": t.get("name", ""), "ID": t.get("id", ""), "Subtask": "Yes" if t.get("subtask") else "", } ) rows.sort(key=lambda r: (r["Subtask"] != "Yes", r["Name"])) print(format_table(rows, ["Name", "ID", "Subtask"])) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to list issue types: {e}") sys.exit(1) if __name__ == "__main__": cli() -
jira-link.py 42.5 KB
#!/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 issue link operations - create links and list link types.""" import csv import json 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, _sanitize_error from lib.input import read_stdin_utf8 from lib.output import error, format_output, format_table, success, 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") @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 issue link operations. Create links between issues and list available link types. """ 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) def _fuzzy_type_match(entries: list[dict], link_type: str) -> tuple[dict | None, list[str]]: """Second-chance match when no link type ``name`` equals the input. Server/DC instances name their types freely (netresearch: ``Relation``, not Cloud's ``Relates``), so an exact-name miss is common. Try, in order: exact match on the outward/inward VERB (``relates to``, ``blocks``), then a unique case-insensitive substring across name + both verbs (``relates`` -> ``Relation`` via ``relates to``). Returns (match, candidate_names); match is None when nothing or more than one candidate matched. """ target = link_type.casefold() for e in entries: if target in (e["outward"].casefold(), e["inward"].casefold()): return e, [e["name"]] hits = [ e for e in entries if target in e["name"].casefold() or target in e["outward"].casefold() or target in e["inward"].casefold() ] if len(hits) == 1: return hits[0], [hits[0]["name"]] return None, sorted(e["name"] for e in hits) def _resolve_link_type_verbs(client, link_type: str) -> dict: """Look up the canonical name + outward/inward verbs for a link type. Match is case-insensitive against the link type's ``name`` so users can pass e.g. ``blocks`` or ``Blocks``; a miss falls back to ``_fuzzy_type_match`` (verbs, unique substring). Raises ValueError if the type is unknown or ambiguous, with a helpful list of names. """ types = client.get_issue_link_types() or [] target = link_type.casefold() entries = [] for entry in types: if not isinstance(entry, dict) or not (entry.get("name") or "").strip(): continue verbs = { "name": (entry.get("name") or "").strip(), "outward": entry.get("outward") or "links to", "inward": entry.get("inward") or "is linked from", } if verbs["name"].casefold() == target: return verbs entries.append(verbs) match, candidates = _fuzzy_type_match(entries, link_type) if match: return match available = ", ".join(sorted(e["name"] for e in entries)) if len(candidates) > 1: raise ValueError(f"Ambiguous link type {link_type!r}: matches {', '.join(candidates)}. Available: {available}") raise ValueError(f"Unknown link type {link_type!r}. Available: {available or '(none returned by Jira)'}") @cli.command() @click.argument("from_key", required=False) @click.argument("to_key", required=False) @click.option("--source", "source_key", help="Source/active actor (outward verb applies). Alias for TO_KEY.") @click.option("--target", "target_key", help="Target/passive recipient (inward verb applies). Alias for FROM_KEY.") @click.option( "--type", "-t", "link_type", help='Link type name (e.g., "Blocks", "Relates"); required. See `list-types` for the names on your Jira.', ) @click.option("--dry-run", is_flag=True, help="Show what would be created") @click.pass_context def create( ctx, from_key: str | None, to_key: str | None, source_key: str | None, target_key: str | None, link_type: str | None, dry_run: bool, ): """Create a link between two issues. Direction (matches Atlassian REST convention): the link is stored such that TO_KEY is the source/active actor (outward verb applies) and FROM_KEY is the destination/passive recipient (inward verb applies). Read 'create FROM TO --type X' as: "on FROM, record that TO does X to it". FROM_KEY: Destination/passive recipient (positional) TO_KEY: Source/active actor (positional) Use --source / --target for an explicit named alternative: --source S --target T --type X is equivalent to create T S --type X Examples: jira-link create FRONTEND-12 INFRA-99 --type Blockade # → "INFRA-99 blocks FRONTEND-12" jira-link create --source INFRA-99 --target FRONTEND-12 --type Blockade # same as above, more explicit jira-link create EFFECT-1 ROOT-2 --type Cause --dry-run """ # Checked here rather than via required=True so the usage error names the # fix: bare `create A B` used to fail with only "Missing option '--type'". if not link_type: raise click.UsageError( "Missing option '--type' / '-t'. Example: jira-link create FROM TO --type Relates " "(run `jira-link list-types` to see the link type names on your Jira)." ) from_key, to_key = _resolve_create_args(from_key, to_key, source_key, target_key) ctx.obj["client"].with_context(issue_key=from_key) client = ctx.obj["client"] verbs = _fetch_verbs_or_exit(client, link_type, ctx.obj["debug"]) canonical_name = verbs["name"] outward_verb = verbs["outward"] sentence = f"{to_key} {outward_verb} {from_key}" if dry_run: warning("DRY RUN - No link will be created") print(f"Would create: {sentence} (link-type: {canonical_name})") return # Atlassian REST convention: inwardIssue is the source of the outward # arrow (active actor), outwardIssue is the destination (passive # recipient). Empirically verified: a stored link with # inwardIssue=A, outwardIssue=B and link type "Cause" is rendered as # "A causes B" / "B is caused by A" by the Jira UI. # In our CLI, TO_KEY is the active actor → inwardIssue=TO_KEY. try: client.create_issue_link( {"type": {"name": canonical_name}, "inwardIssue": {"key": to_key}, "outwardIssue": {"key": from_key}} ) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to create link: {_sanitize_error(str(e))}") sys.exit(1) _emit_create_output( ctx, from_key=from_key, to_key=to_key, canonical_name=canonical_name, verbs=verbs, sentence=sentence ) def _resolve_create_args( from_key: str | None, to_key: str | None, source_key: str | None, target_key: str | None ) -> tuple[str, str]: """Resolve positional FROM/TO vs --source/--target. Mixing the two forms is rejected.""" using_named = source_key is not None or target_key is not None using_positional = from_key is not None or to_key is not None if using_named and using_positional: error("Use either positional FROM_KEY TO_KEY or --source/--target, not both") sys.exit(1) if using_named: if source_key is None or target_key is None: error("Both --source and --target are required when using the named form") sys.exit(1) return target_key, source_key if from_key is None or to_key is None: error("Provide FROM_KEY and TO_KEY (or --source and --target)") sys.exit(1) return from_key, to_key def _fetch_verbs_or_exit(client, link_type: str, debug: bool) -> dict: """Resolve the link type's verbs. On unknown/transport errors, print and exit.""" try: return _resolve_link_type_verbs(client, link_type) except ValueError as e: if debug: raise error(_sanitize_error(str(e))) sys.exit(1) except Exception as e: if debug: raise error(f"Failed to resolve link type: {_sanitize_error(str(e))}") sys.exit(1) def _emit_create_output(ctx, *, from_key: str, to_key: str, canonical_name: str, verbs: dict, sentence: str) -> None: """Render the create result in json / quiet / human form.""" if ctx.obj["json"]: format_output( { "from": from_key, "to": to_key, "source": to_key, "target": from_key, "type": canonical_name, "outward": verbs["outward"], "inward": verbs["inward"], "sentence": sentence, "created": True, }, as_json=True, ) elif ctx.obj["quiet"]: print("ok") else: success(f"Created: {sentence} (link-type: {canonical_name})") @cli.command("list-types") @click.pass_context def list_types(ctx): """List available link types. Shows all issue link types configured in your Jira instance. Example: jira-link list-types """ client = ctx.obj["client"] try: link_types = client.get_issue_link_types() if ctx.obj["json"]: format_output(link_types, as_json=True) elif ctx.obj["quiet"]: for lt in link_types: print(lt.get("name", "")) else: print("Available link types:\n") rows = [] for lt in link_types: rows.append( {"Name": lt.get("name", ""), "Inward": lt.get("inward", ""), "Outward": lt.get("outward", "")} ) print(format_table(rows, ["Name", "Inward", "Outward"])) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get link types: {e}") sys.exit(1) @cli.command("list") @click.argument("issue_key") @click.pass_context def list_cmd(ctx, issue_key: str): """List all issue links on an issue. ISSUE_KEY: The Jira issue key (e.g. PROJ-123) Shows link ID, direction, link type, and the other issue's key and summary. Example: jira-link list PROJ-123 """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: issue = client.issue(issue_key, fields="issuelinks") raw_links = (issue.get("fields") or {}).get("issuelinks") or [] links = [] for link in raw_links: link_id = link.get("id", "") type_obj = link.get("type") or {} type_name = type_obj.get("name", "") if "outwardIssue" in link: other = link["outwardIssue"] direction = "outward" relation = type_obj.get("outward", "") elif "inwardIssue" in link: other = link["inwardIssue"] direction = "inward" relation = type_obj.get("inward", "") else: other = {} direction = "" relation = "" other_key = other.get("key", "") other_summary = ((other.get("fields") or {}).get("summary")) or "" other_status = (((other.get("fields") or {}).get("status")) or {}).get("name", "") links.append( { "id": link_id, "type": type_name, "direction": direction, "relation": relation, "other_key": other_key, "other_summary": other_summary, "other_status": other_status, } ) if ctx.obj["json"]: format_output(links, as_json=True) elif ctx.obj["quiet"]: for link_entry in links: print(f"{link_entry['id']} {link_entry['type']} {link_entry['direction']} {link_entry['other_key']}") else: if not links: print(f"No issue links on {issue_key}") return rows = [ { "ID": link_entry["id"], "Type": link_entry["type"], "Direction": link_entry["direction"], "Other": link_entry["other_key"], "Summary": link_entry["other_summary"][:60], "Status": link_entry["other_status"], } for link_entry in links ] print(format_table(rows, ["ID", "Type", "Direction", "Other", "Summary", "Status"])) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to list issue links for {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) def _link_matches(link: dict, to_key: str, link_type: str) -> bool: """Return True if an issue link targets to_key with link_type (case-insensitive).""" type_name = (link.get("type") or {}).get("name", "") if type_name.lower() != link_type.lower(): return False other = link.get("outwardIssue") or link.get("inwardIssue") or {} other_key = other.get("key", "") return other_key.casefold() == to_key.casefold() def _format_link_display(link: dict, context_key: str | None = None) -> str: """Format an issue link for human-readable output (e.g. 'blocks TEST-2'). When context_key is provided, describe the link from that issue's perspective — matters when both inward and outward are populated (e.g. results from client.get_issue_link(id)). """ type_obj = link.get("type") or {} type_name = type_obj.get("name", "") outward = link.get("outwardIssue") or {} inward = link.get("inwardIssue") or {} ctx_cf = context_key.casefold() if context_key else None if ctx_cf and outward.get("key", "").casefold() == ctx_cf and inward: return f"{type_obj.get('outward', type_name)} {inward.get('key', '?')}" if ctx_cf and inward.get("key", "").casefold() == ctx_cf and outward: return f"{type_obj.get('inward', type_name)} {outward.get('key', '?')}" if outward: return f"{type_obj.get('outward', type_name)} {outward.get('key', '?')}" if inward: return f"{type_obj.get('inward', type_name)} {inward.get('key', '?')}" return type_name @cli.command() @click.argument("issue_key") @click.option("--id", "link_id", type=str, help="Issue link ID (from `jira-link list`)") @click.option("--to", "to_key", help="Other issue key to identify the link by") @click.option("--type", "-t", "link_type", help="Link type name (used with --to)") @click.option("--dry-run", is_flag=True, help="Show what would be deleted") @click.pass_context def delete( ctx, issue_key: str, link_id: str | None, to_key: str | None, link_type: str | None, dry_run: bool, ): """Delete an issue link. ISSUE_KEY: The Jira issue key that owns the link (e.g. PROJ-123) Identify the link by either --id or the combination of --to and --type. Examples: jira-link delete PROJ-123 --id 10042 jira-link delete PROJ-123 --to PROJ-456 --type "Blocks" --dry-run """ if link_id is None and not (to_key and link_type): error("Provide --id, or both --to and --type, to identify the link") sys.exit(1) if link_id is not None and (to_key or link_type): error("Use --id OR (--to and --type), not both") sys.exit(1) ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: # Resolve to a single link_id + display string if link_id is not None: link = client.get_issue_link(link_id) inward_key = (link.get("inwardIssue") or {}).get("key") or "" outward_key = (link.get("outwardIssue") or {}).get("key") or "" if issue_key.casefold() not in {inward_key.casefold(), outward_key.casefold()}: error(f"Link id {link_id} is not associated with issue {issue_key}") sys.exit(1) display = _format_link_display(link, context_key=issue_key) else: issue = client.issue(issue_key, fields="issuelinks") raw_links = (issue.get("fields") or {}).get("issuelinks") or [] matches = [lnk for lnk in raw_links if _link_matches(lnk, to_key, link_type)] if not matches: error(f"No {link_type!r} link between {issue_key} and {to_key}") sys.exit(1) if len(matches) > 1: ids = ", ".join(m.get("id", "?") for m in matches) error(f"Multiple matching links (ids: {ids}); use --id to disambiguate") sys.exit(1) link = matches[0] link_id = link.get("id") if not link_id: error("Matched link has no id; cannot delete") sys.exit(1) display = _format_link_display(link, context_key=issue_key) if dry_run: warning("DRY RUN - No link will be deleted") print(f"Would delete [{link_id}] {display}") return client.remove_issue_link(link_id) if ctx.obj["json"]: format_output({"key": issue_key, "id": link_id, "deleted": True}, as_json=True) elif ctx.obj["quiet"]: print("ok") else: success(f"Deleted link [{link_id}] {display}") except SystemExit: raise except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to delete issue link: {_sanitize_error(str(e))}") sys.exit(1) # ═══════════════════════════════════════════════════════════════════════════════ # bulk-create / bulk-delete / invert: shared helpers # ═══════════════════════════════════════════════════════════════════════════════ def _normalize_csv_rows(reader: csv.DictReader) -> tuple[list[str], list[dict]]: """Strip + casefold field names so 'From' / ' to ' / 'TYPE' all work. The header validator is already case/whitespace-insensitive; without matching normalization on row extraction, headers that pass validation can still produce rows whose `.get('from')` returns None. """ raw_fields = list(reader.fieldnames or []) if not raw_fields: return [], [] fieldnames = [(f or "").strip().casefold() for f in raw_fields] rows: list[dict] = [] for raw_row in reader: rows.append({fieldnames[i]: (raw_row.get(raw_fields[i]) or "") for i in range(len(raw_fields))}) return fieldnames, rows def _open_csv_rows(path: str) -> tuple[list[str], list[dict]]: """Read a CSV (or '-' for stdin) into (fieldnames, rows). Buffers fully. Field names and per-row keys are normalized to lower-case stripped form so headers like `From, To, Type` work the same as `from,to,type`. Returns ([], []) for an empty input (no header, no rows). """ if path == "-": return _normalize_csv_rows(csv.DictReader(sys.stdin)) with open(path, newline="", encoding="utf-8") as f: return _normalize_csv_rows(csv.DictReader(f)) def _validate_bulk_create_header(fieldnames: list[str]) -> None: """Exit 2 with a usage error if any of from/to/type is missing. Field names are already normalized (lower-cased + stripped) by `_open_csv_rows`, so this is a plain set check. """ required = {"from", "to", "type"} missing = required - set(fieldnames) if missing: error(f"CSV header missing required column(s): {', '.join(sorted(missing))}") sys.exit(2) def _build_link_type_cache(client) -> dict: """Single API call → casefolded-name → verbs dict. Used so bulk operations resolve every type name from one HTTP round-trip rather than one per row. """ types = client.get_issue_link_types() or [] cache: dict = {} for entry in types: if not isinstance(entry, dict): continue name = (entry.get("name") or "").strip() if not name: continue cache[name.casefold()] = { "name": name, "outward": entry.get("outward") or "links to", "inward": entry.get("inward") or "is linked from", } return cache def _resolve_type_from_cache(cache: dict, link_type: str) -> dict: """Look up a verbs dict from the cache. Raise ValueError if unknown. Same resolution order as ``_resolve_link_type_verbs``: exact name, then the ``_fuzzy_type_match`` fallback (verbs, unique substring). """ verbs = cache.get(link_type.casefold()) if verbs is not None: return verbs match, candidates = _fuzzy_type_match(list(cache.values()), link_type) if match: return match available = ", ".join(sorted(v["name"] for v in cache.values())) if len(candidates) > 1: raise ValueError(f"Ambiguous link type {link_type!r}: matches {', '.join(candidates)}. Available: {available}") raise ValueError(f"Unknown link type {link_type!r}. Available: {available or '(none returned by Jira)'}") def _existing_link_between( client, from_key: str, to_key: str, type_name: str, links_cache: dict | None = None ) -> dict | None: """Return the first link of *type_name* between FROM and TO, ignoring direction. Reuses the existing `_link_matches` helper (case-insensitive match on type name AND on the other-issue key, in either inward or outward). Pass `links_cache` (a dict keyed by from_key) to memoize the per-issue fetch across rows — avoids the N+1 hit when many rows share the same `from_key`. """ if links_cache is not None and from_key in links_cache: raw = links_cache[from_key] else: issue = client.issue(from_key, fields="issuelinks") raw = (issue.get("fields") or {}).get("issuelinks") or [] if links_cache is not None: links_cache[from_key] = raw for lnk in raw: if _link_matches(lnk, to_key, type_name): return lnk return None def _emit_jsonl(obj: dict) -> None: """One JSON object per line — JSONL, not a pretty-printed array.""" print(json.dumps(obj, default=str)) # ═══════════════════════════════════════════════════════════════════════════════ # bulk-create # ═══════════════════════════════════════════════════════════════════════════════ @cli.command("bulk-create") @click.option( "--from-csv", "csv_path", required=True, help="CSV file path with header 'from,to,type'. Use '-' to read from stdin.", ) @click.option("--dry-run", is_flag=True, help="Resolve verbs and print sentences; do not POST anything.") @click.option( "--continue-on-error/--abort-on-error", "continue_on_error", default=False, help="On a failed row, keep going (default: abort with non-zero exit).", ) @click.option( "--skip-existing", is_flag=True, help="Skip rows where a link of the same type already connects FROM and TO (either direction).", ) @click.pass_context def bulk_create(ctx, csv_path: str, dry_run: bool, continue_on_error: bool, skip_existing: bool): """Create many links from a CSV file. The CSV must have a header row with columns 'from', 'to', and 'type'. Each subsequent row creates one link via the same direction convention as `create FROM TO --type X` ("TO does X to FROM"). \b Example CSV: from,to,type IOS-18,NRS-878,Cause IOS-18,NRT-4388,Deploy IOS-18,NRS-3106,Side effect Examples: jira-link bulk-create --from-csv links.csv --dry-run jira-link bulk-create --from-csv links.csv --skip-existing --continue-on-error cat links.csv | jira-link bulk-create --from-csv - """ try: fieldnames, rows = _open_csv_rows(csv_path) except OSError as e: error(f"Cannot read CSV: {_sanitize_error(str(e))}") sys.exit(1) if not fieldnames and not rows: _emit_bulk_summary(ctx, created=0, skipped=0, failed=0) return _validate_bulk_create_header(fieldnames) client = ctx.obj["client"] try: type_cache = _build_link_type_cache(client) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to fetch link types: {_sanitize_error(str(e))}") sys.exit(1) counts = {"created": 0, "skipped": 0, "failed": 0} # Per-issue links cache: avoid re-fetching the same FROM ticket's # issuelinks for every row that shares it (N+1 → 1 per unique FROM). links_cache: dict = {} total = len(rows) for idx, row in enumerate(rows, start=1): ok = _bulk_create_row( ctx, client, type_cache, row, idx, total, dry_run=dry_run, skip_existing=skip_existing, counts=counts, links_cache=links_cache, ) if not ok and not continue_on_error: _emit_bulk_summary(ctx, **counts) sys.exit(1) _emit_bulk_summary(ctx, **counts) if counts["failed"] and not continue_on_error: sys.exit(1) def _bulk_create_row( ctx, client, type_cache: dict, row: dict, idx: int, total: int, *, dry_run: bool, skip_existing: bool, counts: dict, links_cache: dict | None = None, ) -> bool: """Process a single bulk-create row. Returns True on success or skip, False on failure.""" from_key = (row.get("from") or "").strip() to_key = (row.get("to") or "").strip() raw_type = (row.get("type") or "").strip() if not (from_key and to_key and raw_type): _emit_bulk_row(ctx, idx, total, status="failed", reason="missing from/to/type column value") counts["failed"] += 1 return False try: verbs = _resolve_type_from_cache(type_cache, raw_type) except ValueError as e: _emit_bulk_row(ctx, idx, total, status="failed", reason=_sanitize_error(str(e))) counts["failed"] += 1 return False canonical_name = verbs["name"] sentence = f"{to_key} {verbs['outward']} {from_key}" if skip_existing: try: existing = _existing_link_between(client, from_key, to_key, canonical_name, links_cache=links_cache) except Exception as e: _emit_bulk_row(ctx, idx, total, status="failed", reason=_sanitize_error(str(e))) counts["failed"] += 1 return False if existing is not None: _emit_bulk_row( ctx, idx, total, status="skipped", from_key=from_key, to_key=to_key, type_name=canonical_name, sentence=sentence, ) counts["skipped"] += 1 return True if dry_run: _emit_bulk_row( ctx, idx, total, status="created", sentence=sentence, type_name=canonical_name, from_key=from_key, to_key=to_key, dry_run=True, ) counts["created"] += 1 return True try: client.create_issue_link( { "type": {"name": canonical_name}, "inwardIssue": {"key": to_key}, "outwardIssue": {"key": from_key}, } ) except Exception as e: _emit_bulk_row(ctx, idx, total, status="failed", reason=_sanitize_error(str(e))) counts["failed"] += 1 return False _emit_bulk_row( ctx, idx, total, status="created", sentence=sentence, type_name=canonical_name, from_key=from_key, to_key=to_key, ) counts["created"] += 1 return True def _emit_bulk_row(ctx, idx: int, total: int, *, status: str, **fields) -> None: """Emit a per-row line (text/JSON/quiet).""" if ctx.obj["quiet"]: return if ctx.obj["json"]: payload = {"index": idx, "total": total, "status": status, **fields} _emit_jsonl(payload) return prefix = f"[{idx}/{total}]" if status == "created": sentence = fields.get("sentence", "") type_name = fields.get("type_name", "") if fields.get("dry_run"): print(f"{prefix} Would create: {sentence} (link-type: {type_name})") else: print(f"{prefix} {sentence} (link-type: {type_name})") elif status == "skipped": from_key = fields.get("from_key", "") to_key = fields.get("to_key", "") type_name = fields.get("type_name", "") print(f"{prefix} SKIP existing: {from_key} ↔ {to_key} ({type_name})") elif status == "failed": print(f"{prefix} FAIL: {fields.get('reason', '')}") def _emit_bulk_summary(ctx, *, created: int, skipped: int, failed: int) -> None: """Emit the end-of-run summary.""" if ctx.obj["json"]: _emit_jsonl({"summary": True, "created": created, "skipped": skipped, "failed": failed}) return print(f"created: {created}, skipped: {skipped}, failed: {failed}") # ═══════════════════════════════════════════════════════════════════════════════ # bulk-delete # ═══════════════════════════════════════════════════════════════════════════════ def _read_ids_from_file(path: str) -> list[str]: """Read one ID per line from a file (or stdin if path == '-'). Blank lines ignored.""" if path == "-": text = read_stdin_utf8() else: text = Path(path).read_text(encoding="utf-8") return [ln.strip() for ln in text.splitlines() if ln.strip()] def _resolve_bulk_delete_ids(ids: str | None, ids_file: str | None) -> list[str]: """Resolve --ids / --ids-file (mutually exclusive, exactly one required) to a list.""" if (ids is None) == (ids_file is None): error("Provide exactly one of --ids or --ids-file") sys.exit(1) if ids is not None: return [s.strip() for s in ids.split(",") if s.strip()] try: return _read_ids_from_file(ids_file or "-") except OSError as e: error(f"Cannot read --ids-file: {_sanitize_error(str(e))}") sys.exit(1) @cli.command("bulk-delete") @click.option("--ids", help="Comma-separated link IDs (e.g. '101,102,103').") @click.option( "--ids-file", help="Path with one link ID per line. Use '-' to read from stdin. Mutually exclusive with --ids.", ) @click.option("--dry-run", is_flag=True, help="Show what would be deleted; do not call the API.") @click.option( "--continue-on-error/--abort-on-error", "continue_on_error", default=False, help="On a failed row, keep going (default: abort with non-zero exit).", ) @click.pass_context def bulk_delete(ctx, ids: str | None, ids_file: str | None, dry_run: bool, continue_on_error: bool): """Delete many issue links by ID. Pass IDs as a comma-separated list (--ids) OR as a file with one per line (--ids-file). Each ID is looked up first so the per-row log shows the affected issues, then deleted. Examples: jira-link bulk-delete --ids 101,102,103 --dry-run jira-link bulk-delete --ids-file stale-links.txt --continue-on-error jira-link list PROJ-1 --quiet | awk '{print $1}' | jira-link bulk-delete --ids-file - """ id_list = _resolve_bulk_delete_ids(ids, ids_file) if not id_list: _emit_bulk_delete_summary(ctx, {"created": 0, "failed": 0}) return client = ctx.obj["client"] counts = {"created": 0, "skipped": 0, "failed": 0} # 'created' = deleted in this command's reporting total = len(id_list) for idx, link_id in enumerate(id_list, start=1): ok = _bulk_delete_row(ctx, client, link_id, idx, total, dry_run=dry_run, counts=counts) if not ok and not continue_on_error: _emit_bulk_delete_summary(ctx, counts) sys.exit(1) _emit_bulk_delete_summary(ctx, counts) if counts["failed"] and not continue_on_error: sys.exit(1) def _bulk_delete_row(ctx, client, link_id: str, idx: int, total: int, *, dry_run: bool, counts: dict) -> bool: """Delete one link by ID, with optional pre-fetch to log which issues are affected.""" display = f"link {link_id}" try: link = client.get_issue_link(link_id) inward = (link.get("inwardIssue") or {}).get("key") or "?" outward = (link.get("outwardIssue") or {}).get("key") or "?" type_name = (link.get("type") or {}).get("name") or "?" display = f"[{link_id}] {outward} ↔ {inward} ({type_name})" except Exception as e: if not dry_run: _emit_bulk_delete_line(ctx, idx, total, status="failed", link_id=link_id, reason=_sanitize_error(str(e))) counts["failed"] += 1 return False # In dry-run, lookup failure is not fatal — just keep the bare ID display. if dry_run: _emit_bulk_delete_line(ctx, idx, total, status="dry_run", link_id=link_id, display=display) counts["created"] += 1 return True try: client.remove_issue_link(link_id) except Exception as e: _emit_bulk_delete_line(ctx, idx, total, status="failed", link_id=link_id, reason=_sanitize_error(str(e))) counts["failed"] += 1 return False _emit_bulk_delete_line(ctx, idx, total, status="deleted", link_id=link_id, display=display) counts["created"] += 1 return True def _emit_bulk_delete_line(ctx, idx: int, total: int, *, status: str, link_id: str, **fields) -> None: """Emit a per-row line for bulk-delete.""" if ctx.obj["quiet"]: return if ctx.obj["json"]: _emit_jsonl({"index": idx, "total": total, "status": status, "id": link_id, **fields}) return prefix = f"[{idx}/{total}]" if status == "deleted": print(f"{prefix} Deleted {fields.get('display', link_id)}") elif status == "dry_run": print(f"{prefix} Would delete {fields.get('display', link_id)}") elif status == "failed": print(f"{prefix} FAIL: link {link_id}: {fields.get('reason', '')}") def _emit_bulk_delete_summary(ctx, counts: dict) -> None: """Summary for bulk-delete (renames 'created' → 'deleted' in the public output).""" deleted = counts["created"] failed = counts["failed"] if ctx.obj["json"]: _emit_jsonl({"summary": True, "deleted": deleted, "failed": failed}) return print(f"deleted: {deleted}, failed: {failed}") # ═══════════════════════════════════════════════════════════════════════════════ # invert # ═══════════════════════════════════════════════════════════════════════════════ def _invert_compute_plan(client, link_id: str) -> dict: """Fetch the link and compute the inversion plan. Returns a dict with: id, type_name, original_outward, original_inward, outward_verb, inward_verb, current_sentence, new_sentence. 'Current' direction interpretation (matches the script's create convention): sentence = "<inwardIssue> <outward verb> <outwardIssue>" Inverting swaps the two issue keys. """ link = client.get_issue_link(link_id) type_obj = link.get("type") or {} type_name = type_obj.get("name") or "" if not type_name: raise ValueError(f"Link {link_id} has no type name") outward_verb = type_obj.get("outward") or "links to" inward_verb = type_obj.get("inward") or "is linked from" outward_key = (link.get("outwardIssue") or {}).get("key") or "" inward_key = (link.get("inwardIssue") or {}).get("key") or "" if not (outward_key and inward_key): raise ValueError(f"Link {link_id} is missing outwardIssue/inwardIssue keys") current_sentence = f"{inward_key} {outward_verb} {outward_key}" new_sentence = f"{outward_key} {outward_verb} {inward_key}" return { "id": link_id, "type_name": type_name, "original_outward": outward_key, "original_inward": inward_key, "outward_verb": outward_verb, "inward_verb": inward_verb, "current_sentence": current_sentence, "new_sentence": new_sentence, } def _invert_execute_with_rollback(client, plan: dict) -> None: """Delete the original link and create the inverted one. Rolls back on failure. Raises RuntimeError("INCONSISTENT STATE...") if both the inverted-create AND the rollback re-create fail — that's the case where a human needs to fix Jira manually. """ link_id = plan["id"] type_name = plan["type_name"] original_outward = plan["original_outward"] original_inward = plan["original_inward"] # Capture the original payload BEFORE deletion (so rollback doesn't depend # on the link still existing). original_payload = { "type": {"name": type_name}, "inwardIssue": {"key": original_inward}, "outwardIssue": {"key": original_outward}, } inverted_payload = { "type": {"name": type_name}, "inwardIssue": {"key": original_outward}, "outwardIssue": {"key": original_inward}, } client.remove_issue_link(link_id) try: client.create_issue_link(inverted_payload) except Exception as inverted_exc: try: client.create_issue_link(original_payload) except Exception as rollback_exc: raise RuntimeError( f"INCONSISTENT STATE — original link {link_id} deleted but neither the inverted " f"link ({plan['new_sentence']}) nor the rollback ({plan['current_sentence']}) could " f"be created. Inverted error: {_sanitize_error(str(inverted_exc))}. " f"Rollback error: {_sanitize_error(str(rollback_exc))}." ) from rollback_exc # Rollback succeeded — surface the original failure to the caller. raise RuntimeError( f"Failed to create inverted link ({plan['new_sentence']}); original link restored. " f"Reason: {_sanitize_error(str(inverted_exc))}" ) from inverted_exc @cli.command() @click.option("--id", "link_id", required=True, help="Issue link ID to invert (from `jira-link list`).") @click.option("--dry-run", is_flag=True, help="Show the current and inverted sentences; do not modify Jira.") @click.pass_context def invert(ctx, link_id: str, dry_run: bool): """Invert a link by deleting it and re-creating it with FROM/TO swapped. This is destructive: the original link is DELETED before the new one is created. If the create POST fails, the script attempts to recreate the original (best-effort). If that rollback also fails, you'll get an "INCONSISTENT STATE" error pointing at the link ID — fix it manually in the Jira UI. Always prefer --dry-run first. Examples: jira-link invert --id 10042 --dry-run # → "Would invert: ROOT-2 causes EFFECT-1 → EFFECT-1 causes ROOT-2" jira-link invert --id 10042 """ client = ctx.obj["client"] try: plan = _invert_compute_plan(client, link_id) except ValueError as e: if ctx.obj["debug"]: raise error(_sanitize_error(str(e))) sys.exit(1) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to fetch link {link_id}: {_sanitize_error(str(e))}") sys.exit(1) if dry_run: warning("DRY RUN - No link will be modified") print(f"Would invert: {plan['current_sentence']} → {plan['new_sentence']}") return try: _invert_execute_with_rollback(client, plan) except RuntimeError as e: if ctx.obj["debug"]: raise error(str(e)) sys.exit(1) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to invert link {link_id}: {_sanitize_error(str(e))}") sys.exit(1) _emit_invert_output(ctx, plan) def _emit_invert_output(ctx, plan: dict) -> None: """Emit the success result for invert (json / quiet / human).""" if ctx.obj["json"]: format_output( { "id": plan["id"], "type": plan["type_name"], "old_sentence": plan["current_sentence"], "new_sentence": plan["new_sentence"], "inverted": True, }, as_json=True, ) elif ctx.obj["quiet"]: print("ok") else: success(f"Inverted: {plan['current_sentence']} → {plan['new_sentence']}") if __name__ == "__main__": cli() -
jira-qa-gather.py 14.3 KB
#!/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", # ] # /// """Single-call QA discovery: fetch everything a reviewer needs in one shot. Aggregates issue + description + comments + worklog + structured issue links + web/remote links + URLs extracted from prose (MR/PR/pipeline/commit/tag/release) + sibling tickets, so a QA reviewer (or QA-assistant skill) can read context without making 5+ separate API calls. The description and every comment body are printed in full (same rendering as ``jira-issue.py work``); ``--no-body`` keeps the metadata-only shape. Designed for the peer-qa-review skill but useful for any review workflow. """ 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, _sanitize_error, fetch_comments_paginated from lib.jql import jql_escape from lib.output import error, extract_adf_text, format_output, warning from lib.render import print_comment, print_description # ═══════════════════════════════════════════════════════════════════════════════ # URL patterns reviewers care about (extracted from description + comments) # ═══════════════════════════════════════════════════════════════════════════════ URL_PATTERNS: dict[str, re.Pattern[str]] = { "merge_request": re.compile(r"https?://[^\s\"'|\]]+/-/merge_requests/\d+"), "pull_request": re.compile(r"https?://github\.com/[^\s\"'|\]]+/pull/\d+"), "pipeline": re.compile(r"https?://[^\s\"'|\]]+/-/pipelines/\d+"), "commit": re.compile(r"https?://[^\s\"'|\]]+/-/commit/[a-f0-9]{7,}"), "tag": re.compile(r"https?://[^\s\"'|\]]+/-/tags/[^\s\"'|\]]+"), "release": re.compile(r"https?://github\.com/[^\s\"'|\]]+/releases/[^\s\"'|\]]+"), "issue_link": re.compile(r"https?://[^/\s]+/browse/[A-Z][A-Z0-9_]+-\d+"), } def _extract_urls(text: str) -> dict[str, list[str]]: """Pull review-relevant URLs out of a free-text blob. Returns a dict of category -> deduplicated, order-preserved URL list. """ out: dict[str, list[str]] = {} if not text: return out for category, pattern in URL_PATTERNS.items(): seen: list[str] = [] for match in pattern.findall(text): if match not in seen: seen.append(match) if seen: out[category] = seen return out def _merge_url_dicts(target: dict[str, list[str]], source: dict[str, list[str]]) -> None: """Merge URL extraction results, preserving order and de-duping.""" for category, urls in source.items(): bucket = target.setdefault(category, []) for url in urls: if url not in bucket: bucket.append(url) def _summary_keywords(summary: str) -> list[str]: """Pick token-like substrings from an issue summary for sibling search. Heuristic: keep tokens longer than 3 chars and not in a small stop-list. Used to find sibling tickets mentioning the same component/version. Deduplication is case-insensitive (so 'Jira' and 'jira' don't both pass). """ stop = { "from", "with", "into", "this", "that", "fixes", "fix", "update", "upgrade", "remove", "create", "build", "implement", "support", "issue", "ticket", "task", "and", "the", "for", } tokens: list[str] = [] seen: set[str] = set() for raw in re.findall(r"[A-Za-z][A-Za-z0-9._-]{3,}", summary): token = raw.lower() if token in stop or token in seen: continue seen.add(token) tokens.append(raw) return tokens[:5] def _comment_text(comment: dict) -> str: """Get plain text from a comment, handling both ADF and Server/DC formats.""" body = comment.get("body", "") if isinstance(body, dict): return extract_adf_text(body) or "" return str(body or "") def _safe_message(exc: Exception) -> str: """Render an exception message with credentials/tokens redacted. Mirrors the sanitization done by client.py for connection errors. """ return _sanitize_error(str(exc)) # ═══════════════════════════════════════════════════════════════════════════════ # CLI Definition # ═══════════════════════════════════════════════════════════════════════════════ @click.command() @click.argument("issue_key") @click.option("--json", "output_json", is_flag=True, help="Output as JSON") @click.option("--quiet", "-q", is_flag=True, help="Issue key only (after successful fetch)") @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.option("--no-siblings", is_flag=True, help="Skip sibling-ticket search") @click.option( "--no-body", is_flag=True, help="Metadata only: omit the description and comment bodies from the text output", ) @click.option( "--sibling-window", type=click.IntRange(min=1), default=60, show_default=True, metavar="DAYS", help="Sibling search window", ) @click.option( "--max-siblings", type=click.IntRange(min=1), default=5, show_default=True, metavar="N", help="Max sibling tickets to return", ) def cli( issue_key: str, output_json: bool, quiet: bool, env_file: str | None, profile: str | None, debug: bool, no_siblings: bool, no_body: bool, sibling_window: int, max_siblings: int, ): """Gather everything a QA reviewer needs about an issue in one call. Returns issue + description + all comments (chronological, with author and date, rendered like `jira-issue.py work`) + worklog + structured issue links + web/remote links + URLs extracted from prose (MR/PR/pipeline/commit/tag/ release) + sibling tickets in the same project. No second call is needed to read the ticket text; --no-body restores the metadata-only output. ISSUE_KEY: Jira issue key (e.g., NRS-4365) Examples: jira-qa-gather.py NRS-4365 jira-qa-gather.py NRS-4365 --no-body jira-qa-gather.py NRS-4365 --json """ client = LazyJiraClient(env_file=env_file, profile=profile) client.with_context(issue_key=issue_key) bundle: dict = {"issue_key": issue_key} try: issue = client.issue(issue_key, expand="renderedFields") bundle["issue"] = issue except Exception as exc: if debug: raise error(f"Failed to fetch issue: {_safe_message(exc)}") sys.exit(1) # --quiet: minimal output AFTER a successful fetch (matches jira-issue.py # behaviour). Validates connectivity/permissions/existence before printing. if quiet: print(issue_key) return fields = issue.get("fields", {}) or {} summary = fields.get("summary", "") or "" project_key = (fields.get("project") or {}).get("key", "") or issue_key.split("-")[0] status = (fields.get("status") or {}).get("name", "") # Reviewers decide whether to claim a QA ticket by comparing the current # assignee against themselves, so the bundle has to carry it. `None` is a # meaningful value here (unclaimed team queue), not a missing one. assignee_field = fields.get("assignee") or {} assignee_name = assignee_field.get("name") or assignee_field.get("accountId") or "" assignee_display = assignee_field.get("displayName") or "" description = fields.get("description", "") or "" if isinstance(description, dict): description_text = extract_adf_text(description) or "" else: description_text = str(description) bundle["description"] = fields.get("description") # Comments — the embedded block on the issue payload is capped by Jira # (50 on Server/DC), so paginate like `jira-issue.py work` does and fall # back to the embedded block only if that call fails. comment_block = fields.get("comment") or {} comments: list[dict] = comment_block.get("comments", []) or [] try: comments, _ = fetch_comments_paginated(client, issue_key) except Exception as exc: if debug: raise warning(f"Failed to page through comments, using the embedded block: {_safe_message(exc)}") bundle["comments"] = comments # Worklog worklogs: list[dict] = [] try: worklog_block = client.issue_get_worklog(issue_key) or {} worklogs = worklog_block.get("worklogs", []) or [] except Exception as exc: if debug: raise warning(f"Failed to fetch worklog: {_safe_message(exc)}") bundle["worklogs"] = worklogs bundle["worklog_total_seconds"] = sum(int(w.get("timeSpentSeconds") or 0) for w in worklogs) bundle["assignee"] = assignee_name or None bundle["assignee_display"] = assignee_display or None # Structured issue links + web/remote links bundle["issue_links"] = fields.get("issuelinks", []) or [] web_links: list[dict] = [] try: web_links = client.get_issue_remote_links(issue_key) or [] except Exception as exc: if debug: raise warning(f"Failed to fetch web links: {_safe_message(exc)}") bundle["web_links"] = web_links # Extracted URLs from description + every comment extracted: dict[str, list[str]] = {} _merge_url_dicts(extracted, _extract_urls(description_text)) for comment in comments: _merge_url_dicts(extracted, _extract_urls(_comment_text(comment))) bundle["extracted_urls"] = extracted # Sibling tickets — same project, recently active (resolved OR still open), # with summary keyword overlap. "updated" rather than "resolved" so open # sibling work is included (often the most relevant for QA). siblings: list[dict] = [] if not no_siblings and summary: keywords = _summary_keywords(summary) if keywords: kw_clause = " OR ".join(f'summary ~ "{jql_escape(k)}"' for k in keywords) jql = ( f'project = "{jql_escape(project_key)}" AND key != "{jql_escape(issue_key)}" ' f"AND ({kw_clause}) AND updated >= -{sibling_window}d " f"ORDER BY updated DESC" ) try: results = client.jql(jql, limit=max_siblings, fields="summary,status,resolutiondate,updated") siblings = results.get("issues", []) if isinstance(results, dict) else [] except Exception as exc: if debug: raise warning(f"Sibling search failed: {_safe_message(exc)}") bundle["siblings"] = siblings if output_json: format_output(bundle, as_json=True) return # Human-readable summary print(f"{issue_key}: {summary}") assignee_label = f"{assignee_display} ({assignee_name})" if assignee_name else "Unassigned" print( f"Status: {status} | Assignee: {assignee_label} | Comments: {len(comments)} | " f"Worklog entries: {len(worklogs)} " f"({bundle['worklog_total_seconds'] // 60} min total)" ) if not no_body: print_description(issue) # Printed unconditionally: "none" is a finding (an unlinked related ticket # is a QA check in its own right), whereas an omitted section reads as # "not checked" and invites the reader to assume links exist. if bundle["issue_links"]: print(f"\nIssue links ({len(bundle['issue_links'])}):") for link in bundle["issue_links"]: link_type = (link.get("type") or {}).get("name", "?") other = link.get("outwardIssue") or link.get("inwardIssue") or {} other_key = other.get("key", "?") other_summary = ((other.get("fields") or {}).get("summary") or "").strip() direction = "→" if "outwardIssue" in link else "←" print(f" {link_type} {direction} {other_key}: {other_summary}") else: print("\nIssue links: none") if web_links: print(f"\nWeb/remote links ({len(web_links)}):") for wl in web_links: obj = wl.get("object") or {} print(f" - {obj.get('title', '?')}: {obj.get('url', '?')}") else: print("\nWeb/remote links: none") if extracted: print("\nURLs extracted from description + comments:") for category, urls in extracted.items(): print(f" [{category}] ({len(urls)})") for url in urls: print(f" {url}") if siblings: print(f"\nSibling tickets in {project_key} (last {sibling_window}d):") for sib in siblings: sf = sib.get("fields") or {} print(f" {sib.get('key', '?')}: [{(sf.get('status') or {}).get('name', '?')}] {sf.get('summary', '')}") if not extracted and not siblings and not web_links: print("\n(no review-relevant URLs, web links, or sibling tickets found)") if comments and not no_body: print("\n" + "=" * 60) print(f"COMMENTS ({len(comments)} total — chronological)") print("=" * 60) for c in comments: print_comment(c) print() if __name__ == "__main__": cli() -
jira-user.py 9.2 KB
#!/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 user operations - get user information.""" 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 CaptchaError, LazyJiraClient, _sanitize_error, is_account_id from lib.output import error, format_output from lib.users import find_users # ═══════════════════════════════════════════════════════════════════════════════ # 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 user operations. Get information about Jira users. """ 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) @cli.command() @click.pass_context def me(ctx): """Get current user information. Shows details about the authenticated user. Example: jira-user me """ client = ctx.obj["client"] try: user = client.myself() if ctx.obj["json"]: format_output(user, as_json=True) elif ctx.obj["quiet"]: print(user.get("accountId", user.get("name", ""))) else: print("Current User:") print(f" Name: {user.get('displayName', 'Unknown')}") print(f" Email: {user.get('emailAddress', 'N/A')}") print(f" Account ID: {user.get('accountId', user.get('key', 'N/A'))}") print(f" Active: {'Yes' if user.get('active', True) else 'No'}") timezone = user.get("timeZone", "N/A") print(f" Timezone: {timezone}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get current user: {e}") sys.exit(1) @cli.command() @click.argument("identifier") @click.pass_context def get(ctx, identifier: str): """Get user by identifier. IDENTIFIER: Username, email, or account ID Examples: jira-user get john.doe jira-user get john.doe@example.com jira-user get 5b10ac8d82e05b22cc7d4ef5 """ client = ctx.obj["client"] debug = ctx.obj["debug"] try: # Try different methods to find user user = None # Try by account ID first (Cloud) if is_account_id(identifier): try: user = client.user(account_id=identifier) except Exception as e: if debug: print(f" [debug] account_id lookup failed: {e}", file=sys.stderr) # Try by username directly (Server/DC) if not user: try: user = client.user(username=identifier) except Exception as e: if debug: print(f" [debug] username lookup failed: {e}", file=sys.stderr) # Try user search API (works for email on Server/DC) if not user: try: users = client.get("rest/api/2/user/search", params={"username": identifier}) if users and isinstance(users, list) and len(users) > 0: user = users[0] except Exception as e: if debug: print(f" [debug] user/search API failed: {e}", file=sys.stderr) # Try cloud-aware user search as fallback (username= on Server/DC, query= on Cloud) if not user: try: users = find_users(client, identifier, limit=1) if users: user = users[0] except Exception as e: if debug: print(f" [debug] find_users failed: {e}", file=sys.stderr) if not user: error(f"User not found: {identifier}") sys.exit(1) if ctx.obj["json"]: format_output(user, as_json=True) elif ctx.obj["quiet"]: print(user.get("accountId", user.get("name", ""))) else: print(f"User: {user.get('displayName', 'Unknown')}") print(f" Email: {user.get('emailAddress', 'N/A')}") print(f" Account ID: {user.get('accountId', user.get('key', 'N/A'))}") print(f" Active: {'Yes' if user.get('active', True) else 'No'}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get user {identifier}: {e}") sys.exit(1) @cli.command() @click.argument("query") @click.option("--limit", "-n", default=10, help="Maximum number of results") @click.pass_context def search(ctx, query: str, limit: int): """Search for users by name, username, or email. QUERY: Search term (matches display name, username, or email) Examples: jira-user search doreen jira-user search "john doe" jira-user --json search admin -n 5 """ client = ctx.obj["client"] try: users = [] api_errors = [] # Try Server/DC user search API first try: results = client.get( "rest/api/2/user/search", params={"username": query, "maxResults": limit}, ) if results and isinstance(results, list): # Ensure all results are dicts (Server/DC may return strings) for r in results: if isinstance(r, dict): users.append(r) elif isinstance(r, str): if not ctx.obj["quiet"]: try: users.append(client.user(username=r)) except CaptchaError: raise except Exception as e: if ctx.obj["debug"]: print(f" [debug] Failed to resolve user '{r}': {e}", file=sys.stderr) else: users.append({"name": r}) except CaptchaError: raise except Exception as e: api_errors.append(_sanitize_error(str(e))) if ctx.obj["debug"]: print(f" [debug] user/search API failed: {e}", file=sys.stderr) # Fallback to cloud-aware library search (username= on Server/DC, query= on Cloud) if not users: try: users = find_users(client, query, limit=limit) except CaptchaError: raise except Exception as e: api_errors.append(_sanitize_error(str(e))) if ctx.obj["debug"]: print(f" [debug] find_users failed: {e}", file=sys.stderr) if not users: if api_errors: error("User search failed — all API attempts errored:\n " + "\n ".join(api_errors)) else: error(f"No users found matching: {query}") sys.exit(1) if ctx.obj["json"]: format_output(users, as_json=True) elif ctx.obj["quiet"]: for u in users: print(u.get("accountId", u.get("name", ""))) else: print(f"Found {len(users)} user(s) matching '{query}':\n") for u in users: name = u.get("displayName", "Unknown") uid = u.get("name", u.get("key", u.get("accountId", "N/A"))) email = u.get("emailAddress", "N/A") active = "Yes" if u.get("active", True) else "No" print(f" {name} ({uid})") print(f" Email: {email} Active: {active}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to search users: {_sanitize_error(str(e))}") sys.exit(1) if __name__ == "__main__": cli() -
jira-watchers.py 9 KB
#!/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 watcher operations — list, add, and remove issue watchers.""" 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, resolve_assignee from lib.output import error, format_json, success, 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") @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 watcher operations. List, add, and remove watchers on Jira issues. """ 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) # ═══════════════════════════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════════════════════════ def _resolve_watcher_identifier(client, identifier: str) -> tuple[str, bool]: """Return (value, is_account_id) suitable for issue_add_watcher / issue_delete_watcher. Reuses resolve_assignee() for 'me' / accountId / user-search handling and unwraps the {"name": ...} / {"accountId": ...} dict into a flat string, because the watchers REST API takes a bare identifier as its JSON body (not an object). atlassian-python-api passes the string through verbatim. """ resolved = resolve_assignee(client, identifier) if "accountId" in resolved: return resolved["accountId"], True return resolved["name"], False def _watcher_api_arg(identifier: str, is_account_id_value: bool) -> dict: """Return the keyword arg dict for issue_delete_watcher. DC takes ?username=...; Cloud takes ?accountId=.... atlassian-python-api exposes both as keyword args; pick based on the resolved identifier shape (an account-id-shaped string means Cloud/accountId). """ if is_account_id_value: return {"account_id": identifier} return {"username": identifier} # ═══════════════════════════════════════════════════════════════════════════════ # Subcommands # ═══════════════════════════════════════════════════════════════════════════════ @cli.command("list") @click.argument("issue_key") @click.pass_context def list_watchers(ctx, issue_key: str): """List watchers on an issue. ISSUE_KEY: The Jira issue key (e.g. PROJ-123) Example: jira-watchers list PROJ-123 """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: data = client.issue_get_watchers(issue_key) if ctx.obj["json"]: print(format_json(data)) return watchers = data.get("watchers", []) or [] count = data.get("watchCount", len(watchers)) is_watching = bool(data.get("isWatching")) if ctx.obj["quiet"]: for w in watchers: print(w.get("accountId") or w.get("name", "")) return if not watchers: print(f"No watchers for {issue_key}") return # Jira returns isWatching at the top level describing the caller — # surface it in the header so users know their own subscription state # without a second client.myself() round-trip. status = "you are watching" if is_watching else "you are not watching" print(f"Watchers for {issue_key} ({count}) — {status}:\n") for w in watchers: name = w.get("name") or w.get("accountId", "") display = w.get("displayName", "") print(f" {name:<20} {display}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to list watchers: {e}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.option("--user", default="me", help="Username, accountId, email, or 'me' (default: me)") @click.pass_context def add(ctx, issue_key: str, user: str): """Add a watcher to an issue (default: yourself). ISSUE_KEY: The Jira issue key (e.g. PROJ-123) Adding yourself requires only Browse Projects; adding someone else requires the Manage Watchers permission. Self-adds are idempotent — Jira silently accepts repeated adds. Examples: jira-watchers add PROJ-123 jira-watchers add PROJ-123 --user asmith """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: identifier, _is_acct = _resolve_watcher_identifier(client, user) # POST body is a raw JSON-encoded string (e.g. '"jdoe"'), NOT # {"name": "jdoe"}. atlassian-python-api's issue_add_watcher handles # that correctly — do not wrap in a dict. client.issue_add_watcher(issue_key, identifier) suffix = " (you)" if user.lower() == "me" else "" if ctx.obj["json"]: print(format_json({"key": issue_key, "user": identifier, "added": True})) elif ctx.obj["quiet"]: print("ok") else: success(f"Added watcher to {issue_key}: {identifier}{suffix}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to add watcher: {e}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.option("--user", default="me", help="Username, accountId, email, or 'me' (default: me)") @click.option("--dry-run", is_flag=True, help="Show what would be removed") @click.pass_context def remove(ctx, issue_key: str, user: str, dry_run: bool): """Remove a watcher from an issue (default: yourself). ISSUE_KEY: The Jira issue key (e.g. PROJ-123) Removing yourself requires only Browse Projects; removing someone else requires Manage Watchers. Removing a non-watcher returns 404 — surfaced as a clean error, not a silent success. Examples: jira-watchers remove PROJ-123 jira-watchers remove PROJ-123 --user asmith --dry-run """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: identifier, is_acct = _resolve_watcher_identifier(client, user) suffix = " (you)" if user.lower() == "me" else "" if dry_run: warning("DRY RUN - No watcher will be removed") print(f"Would remove {identifier}{suffix} from {issue_key}") return kwargs = _watcher_api_arg(identifier, is_acct) client.issue_delete_watcher(issue_key, **kwargs) if ctx.obj["json"]: print(format_json({"key": issue_key, "user": identifier, "removed": True})) elif ctx.obj["quiet"]: print("ok") else: success(f"Removed watcher from {issue_key}: {identifier}{suffix}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to remove watcher: {e}") sys.exit(1) if __name__ == "__main__": cli() -
jira-weblink.py 11 KB
#!/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 web link (remote link) operations - add, list, update, delete.""" 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, success, 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") @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 web link (remote link) operations. Add, list, update, and delete external URL links on Jira issues. """ 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) # ═══════════════════════════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════════════════════════ def _resolve_link_by_url(client, issue_key: str, url: str) -> dict: """Find a single remote link by URL, or exit with an error. Returns: The matching remote link dict (with 'id', 'object', etc.). Raises: SystemExit on zero or multiple matches. """ links = client.get_issue_remote_links(issue_key) matches = [link for link in links if link.get("object", {}).get("url") == url] if len(matches) == 0: error(f"No web link found with URL: {url}") sys.exit(1) if len(matches) > 1: error(f"Multiple web links found with URL: {url}. Use --id to specify") sys.exit(1) return matches[0] # ═══════════════════════════════════════════════════════════════════════════════ # Subcommands # ═══════════════════════════════════════════════════════════════════════════════ @cli.command() @click.argument("issue_key") @click.option("--url", required=True, help="URL of the web link") @click.option("--title", required=True, help="Title/label for the web link") @click.option("--dry-run", is_flag=True, help="Show what would be created") @click.pass_context def add(ctx, issue_key: str, url: str, title: str, dry_run: bool): """Add a web link to an issue. ISSUE_KEY: The Jira issue key (e.g. PROJ-123) Examples: jira-weblink add PROJ-123 --url https://example.com/doc --title "Design Doc" jira-weblink add PROJ-123 --url https://ci.example.com --title "CI" --dry-run """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] if dry_run: warning("DRY RUN - No link will be created") print(f"\nWould add web link to {issue_key}: {title} \u2014 {url}") return try: result = client.create_or_update_issue_remote_links(issue_key, url, title) link_id = result.get("id") if isinstance(result, dict) else None if ctx.obj["json"]: data = {"key": issue_key, "url": url, "title": title, "created": True} if link_id is not None: data["id"] = link_id format_output(data, as_json=True) elif ctx.obj["quiet"]: print("ok") else: success(f"Added web link to {issue_key}: {title} \u2014 {url}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to add web link: {e}") sys.exit(1) @cli.command("list") @click.argument("issue_key") @click.pass_context def list_links(ctx, issue_key: str): """List all web links on an issue. ISSUE_KEY: The Jira issue key (e.g. PROJ-123) Example: jira-weblink list PROJ-123 """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: links = client.get_issue_remote_links(issue_key) if ctx.obj["json"]: format_output(links, as_json=True) return if not links: print(f"No web links found for {issue_key}") return if ctx.obj["quiet"]: for link in links: obj = link.get("object", {}) print(f"{link.get('id', '')} {obj.get('url', '')}") return print(f"Web links for {issue_key}:\n") for link in links: link_id = link.get("id", "?") obj = link.get("object", {}) title = obj.get("title", "(untitled)") link_url = obj.get("url", "") print(f" [{link_id}] {title} \u2014 {link_url}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to list web links: {e}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.option("--id", "link_id", type=int, help="Remote link ID") @click.option("--url", help="URL to find the link by (if --id not given)") @click.option("--title", help="New title for the web link") @click.option("--new-url", help="New URL for the web link") @click.pass_context def update(ctx, issue_key: str, link_id: int | None, url: str | None, title: str | None, new_url: str | None): """Update an existing web link. ISSUE_KEY: The Jira issue key (e.g. PROJ-123) Identify the link by --id or --url. At least one of --title or --new-url is required. Examples: jira-weblink update PROJ-123 --id 42 --title "Updated Title" jira-weblink update PROJ-123 --url https://example.com --new-url https://example.com/v2 """ if link_id is None and url is None: error("Provide --id or --url to identify the web link") sys.exit(1) if title is None and new_url is None: error("Provide at least one of --title or --new-url") sys.exit(1) ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: # Resolve link ID if link_id is None: resolved = _resolve_link_by_url(client, issue_key, url) link_id = resolved["id"] # Fetch current link to preserve fields not being updated current = client.get_issue_remote_link_by_id(issue_key, link_id) current_obj = current.get("object", {}) final_url = new_url if new_url is not None else current_obj.get("url") final_title = title if title is not None else current_obj.get("title") if not final_url or not final_title: error("Cannot update: current link is missing url or title. Provide both --new-url and --title.") sys.exit(1) client.update_issue_remote_link_by_id(issue_key, link_id, final_url, final_title) if ctx.obj["json"]: format_output({"key": issue_key, "id": link_id, "updated": True}, as_json=True) elif ctx.obj["quiet"]: print("ok") else: success(f"Updated web link [{link_id}] on {issue_key}") except SystemExit: raise except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to update web link: {e}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.option("--id", "link_id", type=int, help="Remote link ID") @click.option("--url", help="URL to find the link by (if --id not given)") @click.option("--dry-run", is_flag=True, help="Show what would be deleted") @click.pass_context def delete(ctx, issue_key: str, link_id: int | None, url: str | None, dry_run: bool): """Delete a web link from an issue. ISSUE_KEY: The Jira issue key (e.g. PROJ-123) Identify the link by --id or --url. Examples: jira-weblink delete PROJ-123 --id 42 jira-weblink delete PROJ-123 --url https://example.com --dry-run """ if link_id is None and url is None: error("Provide --id or --url to identify the web link") sys.exit(1) ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: # Resolve link for display/ID if link_id is not None: link = client.get_issue_remote_link_by_id(issue_key, link_id) else: link = _resolve_link_by_url(client, issue_key, url) link_id = link["id"] link_obj = link.get("object", {}) link_title = link_obj.get("title", "(untitled)") link_url = link_obj.get("url", "") if dry_run: warning("DRY RUN - No link will be deleted") print(f"Would delete [{link_id}] {link_title} \u2014 {link_url}") return client.delete_issue_remote_link_by_id(issue_key, link_id) if ctx.obj["json"]: format_output({"key": issue_key, "id": link_id, "deleted": True}, as_json=True) elif ctx.obj["quiet"]: print("ok") else: success(f"Deleted web link [{link_id}] from {issue_key}") except SystemExit: raise except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to delete web link: {e}") sys.exit(1) if __name__ == "__main__": cli() -
jira-worklog-query.py 25.8 KB
#!/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", # ] # /// """Cross-cutting worklog query — fetch worklogs by date range, user, project, and more.""" import sys from pathlib import Path # ═══════════════════════════════════════════════════════════════════════════════ # Shared library import # ═══════════════════════════════════════════════════════════════════════════════ _script_dir = Path(__file__).parent _lib_path = _script_dir.parent / "lib" if _lib_path.exists(): sys.path.insert(0, str(_lib_path.parent)) from datetime import date, timedelta import click from lib.client import LazyJiraClient from lib.jql import jql_escape from lib.output import comment_to_text, error, format_json, warning # Backwards-compatible alias (older code paths/tests refer to the private name) _jql_escape = jql_escape # Hard cap on Tempo worklog pagination. A malformed/mocked response whose # metadata never signals "last page" would otherwise loop forever, accumulating # entries until the process exhausts memory. 1000 pages × 1000/page = 1M # worklogs is far beyond any real query. MAX_PAGES = 1000 # ═══════════════════════════════════════════════════════════════════════════════ # Query building # ═══════════════════════════════════════════════════════════════════════════════ def build_jql( from_date: str, to_date: str, user: str | None = None, project: str | None = None, issues: list[str] | None = None, epic: str | None = None, sprint: str | None = None, ) -> str: """Build JQL query from worklog filters.""" clauses = [ f'worklogDate >= "{jql_escape(from_date)}"', f'worklogDate <= "{jql_escape(to_date)}"', ] if user: clauses.append(f'worklogAuthor = "{jql_escape(user)}"') if project: clauses.append(f'project = "{jql_escape(project)}"') if issues: quoted = ", ".join(f'"{jql_escape(k)}"' for k in issues) clauses.append(f"issueKey in ({quoted})") if epic: clauses.append(f'"Epic Link" = "{jql_escape(epic)}"') if sprint: if sprint.isdigit(): clauses.append(f"sprint = {sprint}") else: clauses.append(f'sprint = "{jql_escape(sprint)}"') return " AND ".join(clauses) # ═══════════════════════════════════════════════════════════════════════════════ # Filtering # ═══════════════════════════════════════════════════════════════════════════════ def filter_worklogs( worklogs: list[dict], user: str | None = None, from_date: str | None = None, to_date: str | None = None, ) -> list[dict]: """Client-side filter worklogs by author and date range.""" result = worklogs if user: def _match_user(wl: dict) -> bool: author = wl.get("author", {}) return user in ( author.get("name", ""), author.get("accountId", ""), author.get("displayName", ""), ) result = [wl for wl in result if _match_user(wl)] if from_date: result = [wl for wl in result if wl.get("started", "")[:10] >= from_date] if to_date: result = [wl for wl in result if wl.get("started", "")[:10] <= to_date] return result # ═══════════════════════════════════════════════════════════════════════════════ # Formatting # ═══════════════════════════════════════════════════════════════════════════════ def seconds_to_human(seconds: int) -> str: """Convert seconds to human-readable time (e.g., '2h 30m'). Uses 8h workday for day calculation (Jira default). """ if seconds <= 0: return "0m" days, remainder = divmod(seconds, 28800) # 8h workday hours, remainder = divmod(remainder, 3600) minutes = remainder // 60 parts = [] if days: parts.append(f"{days}d") if hours: parts.append(f"{hours}h") if minutes: parts.append(f"{minutes}m") return " ".join(parts) or "0m" def format_summary(worklogs: list[dict], issues: dict[str, str]) -> str: """Format worklogs as summary table grouped by issue.""" if not worklogs: return "No worklogs found." # Group by issue by_issue: dict[str, int] = {} for wl in worklogs: key = wl.get("_issue_key", "Unknown") by_issue[key] = by_issue.get(key, 0) + wl.get("timeSpentSeconds", 0) lines = [] lines.append(f"{'Issue':<14} {'Summary':<40} {'Time Spent':>10}") lines.append(f"{'-' * 14} {'-' * 40} {'-' * 10}") total_seconds = 0 for key in sorted(by_issue): summary = issues.get(key, "") if len(summary) > 40: summary = summary[:37] + "..." seconds = by_issue[key] total_seconds += seconds lines.append(f"{key:<14} {summary:<40} {seconds_to_human(seconds):>10}") lines.append(f"{'':>14} {'':>40} {'-' * 10}") lines.append(f"{'':>14} {'Total:':>40} {seconds_to_human(total_seconds):>10}") return "\n".join(lines) def format_detail(worklogs: list[dict]) -> str: """Format worklogs as detailed table with individual entries.""" if not worklogs: return "No worklogs found." lines = [] lines.append(f"{'Issue':<14} {'Date':<12} {'Author':<20} {'Time':>8} {'Comment'}") lines.append(f"{'-' * 14} {'-' * 12} {'-' * 20} {'-' * 8} {'-' * 30}") for wl in sorted(worklogs, key=lambda w: w.get("started", "")): key = wl.get("_issue_key", "Unknown") date_str = wl.get("started", "")[:10] author = wl.get("author", {}).get("displayName", "Unknown") if len(author) > 20: author = author[:17] + "..." time_str = seconds_to_human(wl.get("timeSpentSeconds", 0)) comment = comment_to_text(wl.get("comment")) if len(comment) > 50: comment = comment[:47] + "..." lines.append(f"{key:<14} {date_str:<12} {author:<20} {time_str:>8} {comment}") return "\n".join(lines) # ═══════════════════════════════════════════════════════════════════════════════ # Data fetching # ═══════════════════════════════════════════════════════════════════════════════ def search_issues(client, jql: str) -> list[dict]: """Search issues matching JQL, paginated. Returns list of {key, summary}.""" issues = [] start_at = 0 page_size = 50 while True: result = client.jql(jql, start=start_at, limit=page_size, fields=["key", "summary"]) for issue in result.get("issues", []): issues.append( { "key": issue["key"], "summary": issue.get("fields", {}).get("summary", ""), } ) total = result.get("total", 0) fetched = len(result.get("issues", [])) start_at += fetched if fetched else page_size if start_at >= total or fetched == 0: break return issues def _build_issue_map(client, worklogs: list[dict]) -> dict[str, str]: """Map issue keys → summaries via one batched JQL search (avoids N+1 fetches). Falls back to empty summaries if the batch lookup fails. """ issue_keys = {key for wl in worklogs if (key := wl.get("_issue_key", "Unknown")) != "Unknown"} if not issue_keys: return {} quoted = ", ".join(f'"{_jql_escape(k)}"' for k in sorted(issue_keys)) jql = f"issueKey in ({quoted})" try: found = search_issues(client, jql) return {i["key"]: i["summary"] for i in found} except Exception: # Fallback: empty summaries if batch fetch fails return {k: "" for k in issue_keys} def fetch_worklogs(client, issue_key: str) -> list[dict]: """Fetch all worklogs for a single issue. Returns raw worklog dicts.""" result = client.issue_get_worklog(issue_key) worklogs = result.get("worklogs", []) # Tag each worklog with its issue key for later grouping for wl in worklogs: wl["_issue_key"] = issue_key return worklogs def fetch_all_worklogs(client, issues: list[dict]) -> list[dict]: """Fetch worklogs for all issues, with progress indicator.""" total = len(issues) if total > 100: warning(f"Fetching worklogs for {total} issues — this may take a while...") all_worklogs = [] for i, issue in enumerate(issues): if total > 10 and (i + 1) % 10 == 0: click.echo(f" Fetching worklogs... {i + 1}/{total}", err=True) worklogs = fetch_worklogs(client, issue["key"]) all_worklogs.extend(worklogs) return all_worklogs # ═══════════════════════════════════════════════════════════════════════════════ # Tempo backend # ═══════════════════════════════════════════════════════════════════════════════ def detect_tempo(client) -> bool: """Check if the Tempo Timesheets (Server/DC) plugin is installed. Probes the Tempo REST namespace. Note: the v4 ``/worklogs`` resource only accepts POST (``/worklogs/search``); a GET returns **405**, so we must NOT require a 200 here — that misdetects real Tempo Server instances as "no Tempo" and silently falls back to the JQL backend (which cannot see Tempo-only worklogs). A 404 means the plugin is absent (e.g. Tempo Cloud, which uses the separate api.tempo.io API); a 401/403 means we can't use the endpoint anyway, and a 5xx means Jira/Tempo is unhealthy — all treated as "not available" so we don't select the Tempo backend only to fail on the real query. Anything else (200, 400, 405, …) means the plugin is present and reachable. """ try: base_url = client.url.rstrip("/") url = f"{base_url}/rest/tempo-timesheets/4/worklogs" response = client._session.get(url, timeout=5) return response.status_code < 500 and response.status_code not in (401, 403, 404) except Exception: return False def fetch_worklogs_tempo( client, from_date: str, to_date: str, user: str | None = None, project: str | None = None, ) -> tuple[list[dict], dict[str, str]]: """Fetch worklogs from Tempo REST API with native date/user/project filtering. Returns (worklogs, issue_map) where worklogs are normalized to Jira format and issue_map maps issue keys to summaries. """ base_url = client.url.rstrip("/") url = f"{base_url}/rest/tempo-timesheets/4/worklogs" params: dict = { "dateFrom": from_date, "dateTo": to_date, "limit": 1000, "offset": 0, } if user: params["worker"] = user if project: params["projectKey"] = project all_worklogs = [] for _ in range(MAX_PAGES): response = client._session.get(url, params=params, timeout=30) response.raise_for_status() data = response.json() # Handle both array response and paginated object response if isinstance(data, list): all_worklogs.extend(normalize_tempo_worklog(wl) for wl in data) break # Array response = no pagination entries = data.get("results") or data.get("worklogs") or [] all_worklogs.extend(normalize_tempo_worklog(wl) for wl in entries) metadata = data.get("metadata", {}) if not metadata.get("next"): break params["offset"] = metadata.get("offset", 0) + metadata.get("limit", 1000) else: raise RuntimeError( f"Tempo worklog pagination exceeded {MAX_PAGES} pages — aborting to avoid unbounded memory use." ) return all_worklogs, _build_issue_map(client, all_worklogs) def normalize_tempo_worklog(tempo_wl: dict) -> dict: """Convert a Tempo worklog to Jira-compatible format. Normalizes field names and adds _issue_key so existing filter/format functions work unchanged. """ started = tempo_wl.get("started", "") # Tempo returns date-only "2026-04-01"; pad to ISO timestamp for consistency if len(started) == 10: started = f"{started}T00:00:00.000+0000" # The /worklogs endpoint returns a user object in "author"; /worklogs/search returns "worker". # Normalize both so filter/format see a consistent shape. # Guarantee author is always a dict so filter_worklogs/format_detail can # safely call .get() on it. author = tempo_wl.get("author") if isinstance(author, str) and author: # Some payloads put the username directly in "author". author = {"name": author, "displayName": author} elif not isinstance(author, dict) or not author: # No usable author object → derive from worker/updater (search endpoint). author = {} worker = tempo_wl.get("worker") or tempo_wl.get("updater") if isinstance(worker, dict): author = { "name": worker.get("name") or worker.get("accountId", ""), "accountId": worker.get("accountId", ""), "displayName": worker.get("displayName") or worker.get("name") or worker.get("accountId", ""), } elif isinstance(worker, str) and worker: author = {"name": worker, "displayName": worker} elif not author.get("displayName"): # author dict present but missing displayName → fall back to name so # format_detail doesn't render "Unknown" for a known user. author["displayName"] = author.get("name") or "Unknown" return { "id": str(tempo_wl.get("tempoWorklogId", "")), "started": started, "timeSpentSeconds": tempo_wl.get("timeSpentSeconds", 0), "timeSpent": "", "comment": tempo_wl.get("comment", ""), "author": author, "_issue_key": tempo_wl.get("issue", {}).get("key", "Unknown"), } def fetch_worklogs_tempo_account( client, from_date: str, to_date: str, account_keys: list[str], ) -> tuple[list[dict], dict[str, str]]: """Fetch worklogs for one or more Tempo *accounts* via the search endpoint. The plain ``/worklogs`` endpoint filters by worker/project/date but NOT by Tempo account, so the worked time booked to a customer account (across all workers) is only reachable through ``POST /worklogs/search`` with ``accountKey``. Returns ``(worklogs, issue_map)`` in the same shape as :func:`fetch_worklogs_tempo`. """ base_url = client.url.rstrip("/") url = f"{base_url}/rest/tempo-timesheets/4/worklogs/search" payload: dict = { "from": from_date, "to": to_date, "accountKey": account_keys, "limit": 1000, "offset": 0, } all_worklogs: list[dict] = [] for _ in range(MAX_PAGES): response = client._session.post(url, json=payload, timeout=30) response.raise_for_status() data = response.json() if isinstance(data, list): all_worklogs.extend(normalize_tempo_worklog(wl) for wl in data) break entries = data.get("results") or data.get("worklogs") or [] all_worklogs.extend(normalize_tempo_worklog(wl) for wl in entries) metadata = data.get("metadata", {}) next_offset = metadata.get("nextOffset") if next_offset is not None: payload["offset"] = next_offset elif metadata.get("next") or metadata.get("hasMore"): payload["offset"] = metadata.get("offset", 0) + metadata.get("limit", payload["limit"]) else: break else: raise RuntimeError( f"Tempo account worklog pagination exceeded {MAX_PAGES} pages — aborting to avoid unbounded memory use." ) return all_worklogs, _build_issue_map(client, all_worklogs) # ═══════════════════════════════════════════════════════════════════════════════ # CLI Definition # ═══════════════════════════════════════════════════════════════════════════════ @click.command() @click.option("--from", "from_date", help="Start date YYYY-MM-DD (default: Monday of current week)") @click.option("--to", "to_date", help="End date YYYY-MM-DD (default: today)") @click.option("--user", help="Username or accountId (default: current user)") @click.option( "--tempo-account", help=( "Tempo account key(s), comma-separated (e.g. ACME) — worked time " "for a customer ACCOUNT across ALL workers. Forces the Tempo backend; " "ignores --user/--issue/--epic/--sprint/--project." ), ) @click.option("--project", help="Project key (e.g., PROJ)") @click.option("--issue", help="Issue key(s), comma-separated") @click.option("--epic", help="Epic key (e.g., PROJ-1940)") @click.option("--sprint", help="Sprint name or ID") @click.option("--detail", is_flag=True, help="Show individual worklog entries") @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") @click.option("--debug", is_flag=True, help="Show debug information on errors") @click.option( "--backend", type=click.Choice(["auto", "jira", "tempo"]), default="auto", help="Backend: auto (detect Tempo), jira (force JQL), tempo (force Tempo)", ) def cli( from_date, to_date, user, tempo_account, project, issue, epic, sprint, detail, output_json, quiet, env_file, profile, debug, backend, ): """Query worklogs across issues with flexible filters. Default: current user's worklogs for the current week, grouped by issue. Examples: jira-worklog-query.py # my week jira-worklog-query.py --project PROJ # my week on PROJ jira-worklog-query.py --from 2026-03-01 --to 2026-03-31 --detail """ client = LazyJiraClient(env_file=env_file, profile=profile) try: # Resolve defaults today = date.today() if not from_date: monday = today - timedelta(days=today.weekday()) from_date = monday.isoformat() if not to_date: to_date = today.isoformat() # Parse issue list early for profile resolution issue_list = [k.strip() for k in issue.split(",") if k.strip()] if issue else None # Set client context for multi-profile resolution before any API call context_key = epic or (issue_list[0] if issue_list else None) if context_key: client.with_context(issue_key=context_key) # Tempo account mode spans all workers → no per-user resolution/filter account_keys = [k.strip() for k in tempo_account.split(",") if k.strip()] if tempo_account is not None else None if tempo_account is not None and not account_keys: raise click.BadParameter( "must contain at least one non-empty account key", param_hint="--tempo-account", ) # Resolve user default (not used in account mode) effective_user = user if not account_keys and not effective_user: me = client.myself() effective_user = me.get("name") or me.get("accountId", "") # Subject shown in output headers subject = f"account {', '.join(account_keys)}" if account_keys else effective_user # Determine backend (account mode is Tempo-only) use_tempo = False if account_keys or backend == "tempo": use_tempo = True elif backend == "auto": use_tempo = detect_tempo(client) if use_tempo and debug: click.echo("Tempo detected — using Tempo API", err=True) # Forced Tempo paths (--tempo-account, --backend tempo) still need the # Tempo Server/DC REST API to actually be present. --backend auto already # reflects detection above, so only re-check when Tempo was forced. if use_tempo and (account_keys or backend == "tempo") and not detect_tempo(client): error( "Tempo Timesheets REST API (/rest/tempo-timesheets/4) is not available or not " "reachable on this Jira (plugin absent, auth failure, or a server error). " "'--tempo-account' and '--backend tempo' require Tempo on Jira Server/DC; " "Tempo Cloud (api.tempo.io) is a different API and is not supported." ) sys.exit(1) if use_tempo and account_keys: # Tempo account path: worked time for a customer account, all workers if user or issue_list or epic or sprint or project: warning("--tempo-account ignores --user/--issue/--epic/--sprint/--project filters.") if debug: click.echo(f"Tempo account query: {from_date} to {to_date}, accounts={account_keys}", err=True) all_worklogs, issue_map = fetch_worklogs_tempo_account(client, from_date, to_date, account_keys) # Account query is already scoped by account; filter by date only. filtered = filter_worklogs(all_worklogs, user=None, from_date=from_date, to_date=to_date) elif use_tempo: # Tempo path: native filtering, no JQL needed # Note: Tempo doesn't support issue/epic/sprint filters natively, # so we warn if those are specified if issue_list or epic or sprint: warning("Tempo backend does not support --issue, --epic, or --sprint filters. Ignoring them.") if debug: click.echo(f"Tempo query: {from_date} to {to_date}, user={effective_user}, project={project}", err=True) all_worklogs, issue_map = fetch_worklogs_tempo( client, from_date, to_date, user=effective_user, project=project ) # Client-side filter (Tempo already filters by user/date/project, # but we still filter for consistency and to handle edge cases) filtered = filter_worklogs(all_worklogs, user=effective_user, from_date=from_date, to_date=to_date) else: # Jira REST path: JQL search + per-issue fetch jql = build_jql( from_date, to_date, user=effective_user, project=project, issues=issue_list, epic=epic, sprint=sprint ) if debug: click.echo(f"JQL: {jql}", err=True) issues = search_issues(client, jql) if not issues: if output_json: click.echo("[]") elif not quiet: click.echo(f"No issues found with worklogs for {from_date} to {to_date}") return all_worklogs = fetch_all_worklogs(client, issues) filtered = filter_worklogs(all_worklogs, user=effective_user, from_date=from_date, to_date=to_date) issue_map = {i["key"]: i["summary"] for i in issues} # Handle empty results (common path for both backends) if not filtered: if output_json: click.echo("[]") elif not quiet: click.echo(f"No worklogs found for {from_date} to {to_date}") return # Output if output_json: click.echo(format_json(filtered)) elif quiet: total_seconds = sum(wl.get("timeSpentSeconds", 0) for wl in filtered) click.echo(seconds_to_human(total_seconds)) elif detail: backend_label = " (via Tempo)" if use_tempo else "" header = f"Worklogs for {subject} | {from_date} -> {to_date}{backend_label}" click.echo(header) click.echo() click.echo(format_detail(filtered)) else: backend_label = " (via Tempo)" if use_tempo else "" header = f"Worklogs for {subject} | {from_date} -> {to_date}{backend_label}" click.echo(header) click.echo() click.echo(format_summary(filtered, issue_map)) except Exception as e: if debug: raise error(f"Failed to query worklogs: {e}") sys.exit(1) if __name__ == "__main__": cli()
-
-
workflow
-
jira-board.py 6.9 KB
#!/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 board operations - list boards and get board issues.""" 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 from lib.users import person_label # ═══════════════════════════════════════════════════════════════════════════════ # 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 board operations. List agile boards and get board issues. """ 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) @cli.command("list") @click.option("--project", "-p", help="Filter by project key") @click.option("--type", "-t", "board_type", type=click.Choice(["scrum", "kanban"]), help="Filter by board type") @click.option("--name", "-n", "name_pattern", help="Filter by board name (server-side partial match)") @click.pass_context def list_boards(ctx, project: str | None, board_type: str | None, name_pattern: str | None): """List agile boards. Examples: jira-board list jira-board list --project PROJ jira-board list --type scrum jira-board list --name Lithium # server-side partial match on board name """ client = ctx.obj["client"] try: params = {} if project: params["projectKeyOrId"] = project if board_type: params["type"] = board_type if name_pattern: params["name"] = name_pattern boards: list[dict] = [] start_at = 0 while True: page_params = dict(params) page_params["startAt"] = start_at response = client.get("rest/agile/1.0/board", params=page_params) or {} values = response.get("values", []) or [] boards.extend(values) is_last = bool(response.get("isLast")) if is_last or not values: break start_at += len(values) if ctx.obj["json"]: format_output(boards, as_json=True) elif ctx.obj["quiet"]: for b in boards: print(b.get("id", "")) else: if not boards: print("No boards found") if project: print(f" (filtered by project: {project})") if name_pattern: print(f" (filtered by name: {name_pattern})") else: print(f"Agile boards ({len(boards)} found):\n") rows = [] for b in boards: loc = b.get("location", {}) rows.append( { "ID": b.get("id", ""), "Name": b.get("name", ""), "Type": b.get("type", ""), "Project": loc.get("projectKey", "-"), } ) print(format_table(rows, ["ID", "Name", "Type", "Project"])) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to list boards: {e}") sys.exit(1) @cli.command() @click.argument("board_id", type=int) @click.option("--jql", help="Additional JQL filter") @click.option("--max-results", "-n", default=50, help="Maximum results") @click.pass_context def issues(ctx, board_id: int, jql: str | None, max_results: int): """Get issues on a board. BOARD_ID: The Jira agile board ID Examples: jira-board issues 42 jira-board issues 42 --jql "status = 'In Progress'" jira-board issues 42 --max-results 20 """ client = ctx.obj["client"] try: params = {"maxResults": max_results} if jql: params["jql"] = jql response = client.get(f"rest/agile/1.0/board/{board_id}/issue", params=params) issues_list = response.get("issues", []) if ctx.obj["json"]: format_output(issues_list, as_json=True) elif ctx.obj["quiet"]: for issue in issues_list: print(issue["key"]) else: if not issues_list: print(f"No issues on board {board_id}") if jql: print(f" (filtered by JQL: {jql})") else: print(f"Issues on board {board_id} ({len(issues_list)} shown):\n") rows = [] for issue in issues_list: fields = issue.get("fields", {}) status = fields.get("status", {}).get("name", "-") assignee = fields.get("assignee", {}) assignee_name = person_label(assignee, fallback="-") if assignee else "-" summary = fields.get("summary", "") if len(summary) > 40: summary = summary[:37] + "..." rows.append({"Key": issue["key"], "Summary": summary, "Status": status, "Assignee": assignee_name}) print(format_table(rows, ["Key", "Summary", "Status", "Assignee"])) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get issues for board {board_id}: {e}") sys.exit(1) if __name__ == "__main__": cli() -
jira-comment.py 14.7 KB
#!/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,<3", # ] # /// """Jira comment operations - add, edit, delete, and list issue comments.""" 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, _sanitize_error, fetch_comments_paginated from lib.input import read_stdin_utf8 from lib.markup_cli import MarkupGates, guard_wiki_markup, markup_options from lib.output import error, extract_adf_text, format_output, success, warning from lib.users import check_mentions_cli, person_label def _read_comment_text(comment_text: str, usage: str) -> str: """Return the comment body, reading stdin when the argument is ``-``. Extracted because `add` and `edit` carried a byte-identical copy of this apart from the usage line, and the pre-flight check pushed both functions past the cognitive-complexity gate. """ if comment_text != "-": return comment_text if sys.stdin.isatty(): error("'-' requires piped input but stdin is a terminal", suggestion=usage) sys.exit(1) max_size = 256 * 1024 # 256KB, above Jira's comment limit try: comment_text = read_stdin_utf8(max_size + 1) except UnicodeDecodeError: error( "stdin contains invalid text encoding (expected UTF-8)", suggestion="Ensure the piped file is valid UTF-8 text, not binary data.", ) sys.exit(1) if len(comment_text) > max_size: error( f"stdin input exceeds maximum size ({max_size // 1024}KB)", suggestion="Jira comments have size limits. Consider attaching the content as a file.", ) sys.exit(1) comment_text = comment_text.rstrip("\n") if not comment_text.strip(): error( "No input received from stdin (empty or whitespace-only)", suggestion="Verify your piped command produces non-empty output.", ) sys.exit(1) return comment_text # ═══════════════════════════════════════════════════════════════════════════════ # CLI Definition # ═══════════════════════════════════════════════════════════════════════════════ # Backwards-compat alias for any external imports — implementation moved to lib.client. _fetch_comments_paginated = fetch_comments_paginated @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 comment operations. Add, edit, delete, and list comments on Jira issues. Note: Comments should use Jira wiki markup syntax, not Markdown. """ 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) # Kept so the render pre-flight resolves the SAME instance the write goes # to. Without them it falls back to ~/.env.jira, which under --profile is # either absent (silently degraded forever) or a DIFFERENT Jira - and a # clean preview from the wrong instance reads as a clean bill of health. ctx.obj["env_file"] = env_file ctx.obj["profile"] = profile @cli.command() @click.argument("issue_key") @click.argument("comment_text") @markup_options @click.option("--no-verify-mentions", is_flag=True, help="Skip [~username] mention verification") @click.pass_context def add( ctx, issue_key: str, comment_text: str, gates: MarkupGates, no_verify_mentions: bool, ): """Add a comment to an issue. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) COMMENT_TEXT: Comment text (use Jira wiki markup, not Markdown). Use "-" to read from stdin (e.g., cat file.txt | jira-comment add PROJ-123 -) Note: Use Jira wiki syntax: - *bold* not **bold** - _italic_ not *italic* - {code}...{code} blocks for multi-line code, {{monospace}} for inline - [link text|url] for links, [^file.log] for attachments Block tags ({code}, {noformat}, {quote}, {panel}) must stand alone on their own line; literal mentions in prose must be escaped (\\{code\\}). Dashes Jira would render as a strikethrough span (``{{mono}}-Word ... zu-``) are escaped automatically before posting and reported on stderr; ``\\-`` prints as a plain hyphen. --no-auto-escape keeps the markup verbatim; a deliberate strikethrough also needs --force, because the lint and the render check each still refuse the span. The comment is linted for this before posting (override with --force). [~username] mentions are verified against Jira before posting, so no separate user lookup is needed; an unknown username aborts with suggestions (skip with --no-verify-mentions). Examples: jira-comment add PROJ-123 "Fixed in commit abc123" jira-comment add PROJ-123 "See {{config.py}} for details" jira-comment add PROJ-123 "[~jane.doe] please review" cat comment.txt | jira-comment add PROJ-123 - """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] comment_text = _read_comment_text(comment_text, "Usage: cat comment.txt | jira-comment add PROJ-123 -") comment_text = guard_wiki_markup( comment_text, gates=gates, issue_key=issue_key, env_file=ctx.obj.get("env_file"), profile=ctx.obj.get("profile"), label="comment", ) check_mentions_cli(client, comment_text, skip=no_verify_mentions) try: result = client.issue_add_comment(issue_key, comment_text) if ctx.obj["quiet"]: print(result.get("id", "ok")) elif ctx.obj["json"]: format_output(result, as_json=True) else: success(f"Added comment to {issue_key}") print(f" Comment ID: {result.get('id', 'N/A')}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to add comment to {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.argument("comment_id") @click.argument("comment_text") @markup_options @click.option("--no-verify-mentions", is_flag=True, help="Skip [~username] mention verification") @click.pass_context def edit( ctx, issue_key: str, comment_id: str, comment_text: str, gates: MarkupGates, no_verify_mentions: bool, ): """Edit an existing comment on an issue. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) COMMENT_ID: The ID of the comment to edit (use 'list' to find IDs) COMMENT_TEXT: New comment text (use Jira wiki markup, not Markdown). Use "-" to read from stdin (e.g., cat file.txt | jira-comment edit PROJ-123 12345 -). Linted for wiki-markup problems before posting (override with --force). Dashes Jira would render as a strikethrough span (``{{mono}}-Word ... zu-``) are escaped automatically before posting and reported on stderr; ``\\-`` prints as a plain hyphen. --no-auto-escape keeps the markup verbatim; a deliberate strikethrough also needs --force, because the lint and the render check each still refuse the span. Examples: jira-comment edit PROJ-123 12345 "Updated: fixed in commit abc123" jira-comment edit PROJ-123 12345 "h3. Findings\\n\\nUpdated analysis" cat comment.txt | jira-comment edit PROJ-123 12345 - """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] comment_text = _read_comment_text(comment_text, "Usage: cat comment.txt | jira-comment edit PROJ-123 12345 -") comment_text = guard_wiki_markup( comment_text, gates=gates, issue_key=issue_key, env_file=ctx.obj.get("env_file"), profile=ctx.obj.get("profile"), label="comment", ) check_mentions_cli(client, comment_text, skip=no_verify_mentions) try: result = client.issue_edit_comment(issue_key, comment_id, comment_text) if ctx.obj["quiet"]: if isinstance(result, dict): print(result.get("id", "ok")) else: print("ok") elif ctx.obj["json"]: format_output(result, as_json=True) else: success(f"Updated comment {comment_id} on {issue_key}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to edit comment {comment_id} on {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) @cli.command() @click.argument("issue_key") @click.argument("comment_id") @click.option("--dry-run", is_flag=True, help="Show what would be deleted without making changes") @click.pass_context def delete(ctx, issue_key: str, comment_id: str, dry_run: bool): """Delete a comment from an issue. Non-interactive: there is no confirmation prompt, the comment is deleted on the spot. Preview with --dry-run; no stdin is read. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) COMMENT_ID: The ID of the comment to delete (use 'list' to find IDs) Examples: jira-comment delete PROJ-123 12345 jira-comment delete PROJ-123 12345 --dry-run """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] if dry_run: warning("DRY RUN - No comment will be deleted") print(f"\nWould delete comment {comment_id} from {issue_key}") return try: url = f"rest/api/2/issue/{issue_key}/comment/{comment_id}" client.delete(url) if ctx.obj["quiet"]: print("ok") elif ctx.obj["json"]: format_output({"issue_key": issue_key, "comment_id": comment_id, "deleted": True}, as_json=True) else: success(f"Deleted comment {comment_id} from {issue_key}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to delete comment {comment_id} from {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) @cli.command("list") @click.argument("issue_key") @click.option( "--limit", "-n", default=10, show_default=True, type=click.IntRange(min=0), help="Max comments to show (0 = all)", ) @click.option("--truncate", type=int, metavar="N", help="Truncate comment body to N characters") @click.pass_context def list_comments(ctx, issue_key: str, limit: int, truncate: int | None): """List comments on an issue. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) Examples: jira-comment list PROJ-123 jira-comment list PROJ-123 --limit 5 --json """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: show_all = limit == 0 if show_all: comments, total = _fetch_comments_paginated(client, issue_key) else: issue = client.issue(issue_key, fields="comment") comment_block = (issue.get("fields") or {}).get("comment") or {} comments = comment_block.get("comments", []) or [] total = comment_block.get("total") # Limit and reverse (newest first) comments = list(reversed(comments)) shown = comments if show_all else comments[:limit] # The truncation notice belongs on stderr, in every output mode. On # stdout it is part of the payload: a caller that pipes the table # through grep, or parses --json, drops the one line saying the history # is incomplete and reads 10 of 163 comments as the whole record. That # happened -- a ticket was reported as carrying no "ready for QA" # comment when it did, because the read was cut and the cut was # invisible. stderr survives the pipe; stdout does not. truncated = total is not None and not show_all and len(shown) < total if truncated: warning(f"{issue_key}: showing {len(shown)} of {total} comments — use --limit 0 for the full history") if ctx.obj["json"]: format_output(shown, as_json=True) elif ctx.obj["quiet"]: for c in shown: print(c.get("id", "")) else: if not shown: print(f"No comments on {issue_key}") else: if truncated: print(f"Comments on {issue_key} ({len(shown)} of {total} shown — use --limit 0 to show all):\n") else: print(f"Comments on {issue_key} ({len(shown)} shown):\n") for c in shown: author = person_label(c.get("author")) created = c.get("created", "")[:16].replace("T", " ") if c.get("created") else "N/A" body = c.get("body", "") # Handle ADF format if isinstance(body, dict): body = extract_adf_text(body) # Truncate if requested if truncate and len(body) > truncate: body = body[: truncate - 3] + "..." print("-" * 80) print(f"[{created}] {author}:") print() for line in body.split("\n"): print(line) print() except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get comments for {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) if __name__ == "__main__": cli() -
jira-create.py 16.9 KB
#!/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 issue creation - create new issues with various types and fields.""" import json 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, resolve_assignee, resolve_subtask_type from lib.input import read_stdin_utf8 from lib.markup_cli import MarkupGates, guard_wiki_markup, markup_options from lib.output import error, format_output, success, warning from lib.users import check_mentions_cli # ═══════════════════════════════════════════════════════════════════════════════ # 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 (just issue key)") @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 issue creation. Create new Jira issues with various types and configurations. """ 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) # Kept for callers that resolve the config themselves rather than through # the client - the render preview does. Without them guard_wiki_markup # reads None and previews against the DEFAULT profile, which is a # different tenant from the one this command is writing to. ctx.obj["env_file"] = env_file ctx.obj["profile"] = profile @cli.command() @click.argument("project_key") @click.argument("summary") @click.option("--type", "-t", "issue_type", required=True, help="Issue type (Task, Bug, Story, Epic, etc.)") @click.option("--description", "-d", help="Issue description (Jira wiki markup; '-' reads from stdin)") @click.option("--priority", "-p", help="Priority name (High, Medium, Low, etc.)") @click.option("--labels", "-l", help="Comma-separated labels") @click.option("--assignee", "-a", help="Assignee username or email") @click.option("--reporter", "-r", help="Reporter username or email") @click.option("--parent", help="Parent issue key (creates a subtask)") @click.option("--components", help="Comma-separated component names") @click.option("--fields-json", help="JSON string of additional fields") @click.option("--no-verify-mentions", is_flag=True, help="Skip [~username] mention verification in --description") @markup_options @click.option("--dry-run", is_flag=True, help="Show what would be created without making changes") @click.pass_context def issue( ctx, project_key: str, summary: str, issue_type: str, description: str | None, priority: str | None, labels: str | None, assignee: str | None, reporter: str | None, parent: str | None, components: str | None, fields_json: str | None, no_verify_mentions: bool, gates: MarkupGates, dry_run: bool, ): """Create a new Jira issue. PROJECT_KEY: The Jira project key (e.g., PROJ) SUMMARY: Issue summary/title Examples: jira-create issue PROJ "Fix login timeout" --type Bug --priority High jira-create issue PROJ "New feature" --type Story --parent PROJ-100 jira-create issue PROJ "API documentation" --type Task -d "Update API docs" -l docs,api cat body.txt | jira-create issue PROJ "Long description" --type Task -d - jira-create issue PROJ "Bug from QA" --type Bug --reporter jane.doe jira-create issue PROJ "Sprint goal" --type Epic jira-create issue PROJ "Test" --type Task --dry-run """ client = ctx.obj["client"] # Build issue fields fields = { "project": {"key": project_key}, "summary": summary, "issuetype": {"name": issue_type}, } if description == "-": # Same convention as `jira-issue update`. Without this, "-" was stored # verbatim and the issue was created with a one-character description — # the create call still reported success, so the loss surfaced only when # someone opened the ticket. if sys.stdin.isatty(): error( "'-' requires piped input but stdin is a terminal", suggestion="Usage: cat body.txt | jira-create issue PROJ 'Summary' --description -", ) sys.exit(1) max_size = 256 * 1024 # 256KB, above Jira's description limit try: description = read_stdin_utf8(max_size + 1) except UnicodeDecodeError: error( "stdin contains invalid text encoding (expected UTF-8)", suggestion="Ensure the piped file is valid UTF-8 text, not binary data.", ) sys.exit(1) if len(description) > max_size: error( f"description from stdin exceeds {max_size} bytes", suggestion="Truncate the input or split it across a create plus an update.", ) sys.exit(1) description = description.rstrip("\n") if description: # A description renders wiki markup — same gates as jira-comment add description = guard_wiki_markup( description, gates=gates.offline() if dry_run else gates, # The issue does not exist yet, so there is no key. The language # lint only reads the project part of one, and that IS known - # is_english_only_project() splits on the first dash. The renderer # wants a real issue and 404s on a project key, so it gets none. issue_key=project_key, render_issue_key=None, env_file=ctx.obj.get("env_file"), profile=ctx.obj.get("profile"), label="description", ) check_mentions_cli(client, description, skip=no_verify_mentions) fields["description"] = description if priority: fields["priority"] = {"name": priority} if labels: fields["labels"] = [lbl.strip() for lbl in labels.split(",")] if assignee: fields["assignee"] = resolve_assignee(client, assignee) if reporter: fields["reporter"] = resolve_assignee(client, reporter) if parent: # Resolve issue type to a valid subtask type for the target project resolved_type = resolve_subtask_type(client, project_key, issue_type) if resolved_type is None: error( f"Project {project_key} has no subtask issue types matching '{issue_type}'", suggestion=f"Run: uv run scripts/utility/jira-fields.py types {project_key} to list available types", ) sys.exit(1) if resolved_type != issue_type: warning(f"Resolved issue type '{issue_type}' → '{resolved_type}' (subtask type for {project_key})") fields["issuetype"] = {"name": resolved_type} issue_type = resolved_type fields["parent"] = {"key": parent} if components: fields["components"] = [{"name": c.strip()} for c in components.split(",")] if fields_json: try: extra_fields = json.loads(fields_json) fields.update(extra_fields) except json.JSONDecodeError as e: error(f"Invalid JSON in --fields-json: {e}") sys.exit(1) # Dry run if dry_run: warning("DRY RUN - No issue will be created") print(f"\nWould create issue in {project_key}:") print(f" Type: {issue_type}") print(f" Summary: {summary}") if description: print(f" Description: {description[:50]}...") if priority: print(f" Priority: {priority}") if labels: print(f" Labels: {labels}") if assignee: print(f" Assignee: {assignee}") if reporter: print(f" Reporter: {reporter}") if parent: print(f" Parent: {parent}") if components: print(f" Components: {components}") return try: result = client.create_issue(fields=fields) if ctx.obj["quiet"]: print(result["key"]) elif ctx.obj["json"]: format_output(result, as_json=True) else: success(f"Created issue: {result['key']}") print(f" Summary: {summary}") print(f" Type: {issue_type}") print(f" URL: {client.url}/browse/{result['key']}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to create issue: {e}") sys.exit(1) @cli.command() @click.argument("key") @click.argument("name") @click.option( "--from-project", "source_project", required=True, help="Key or ID of an existing project whose configuration (schemes) to copy", ) @click.option("--lead", required=True, help="Username of the new project's lead") @click.option( "--bootstrap-issues", is_flag=True, help="Create the XXX-1 config-hub issue ('Projektmanagement') after project creation", ) @click.option( "--force", is_flag=True, help="Skip the historical-key-collision check (see below) and proceed anyway", ) @click.option("--dry-run", is_flag=True, help="Show what would be created without making changes") @click.pass_context def project( ctx, key: str, name: str, source_project: str, lead: str, bootstrap_issues: bool, force: bool, dry_run: bool, ): """Create a new Jira project by copying configuration from an existing project. KEY: The new project's key (e.g., NEWP) NAME: The new project's display name (e.g., "Example Customer GmbH") Uses Jira's "shared configuration" mechanism (the same one behind the UI's "Share settings with an existing project" option) to copy the permission, notification and workflow schemes from --from-project, so the new project matches an existing convention without manually specifying scheme IDs. Before creating, checks whether KEY-1 already resolves to an issue. Jira keeps project-key renames as permanent redirects (rename a project's key and its old key still resolves to the same issues forever) — reusing an old, renamed-away key silently skips however many issue numbers that key already used, so the new project's first bootstrap issue would NOT be KEY-1. Use --force to proceed anyway if this is expected. Examples: jira-create project NEWP "Example Customer GmbH" --from-project TMPL --lead jane.doe jira-create project OPSNEWP "OPS Example Customer GmbH" --from-project OPS --lead jane.doe --bootstrap-issues """ client = ctx.obj["client"] try: source = client.project(source_project) except Exception as e: error(f"Could not resolve --from-project '{source_project}': {e}") sys.exit(1) source_id = source.get("id") if isinstance(source, dict) else None if not source_id: error(f"Project '{source_project}' has no numeric id in the API response") sys.exit(1) collision = _check_key_collision(client, key) if collision and not force: error( f"'{key}-1' already resolves to an existing issue ({collision}). " f"This key was likely used by a project since renamed away from it — Jira will " f"silently skip numbering, so the new project's first issue would NOT be {key}-1. " f"Pick a different key, or pass --force to proceed anyway." ) sys.exit(1) elif collision: warning(f"Proceeding despite '{key}-1' already resolving to {collision} (--force)") if dry_run: warning("DRY RUN - No project will be created") print("\nWould create project:") print(f" Key: {key}") print(f" Name: {name}") print(f" Lead: {lead}") print(f" Copying schemes from: {source_project} (id={source_id})") if bootstrap_issues: print(f" Would create bootstrap issue: {key}-1 (Projektmanagement, Issue Number One)") return try: result = client.create_project_from_shared_template(source_id, key, name, lead) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to create project: {e}") sys.exit(1) if ctx.obj["quiet"]: print(key) elif ctx.obj["json"]: format_output(result, as_json=True) else: success(f"Created project: {key}") print(f" Name: {name}") print(f" Lead: {lead}") print(f" Configuration copied from: {source_project}") print(f" URL: {client.url}/browse/{key}") if bootstrap_issues: _create_bootstrap_issues(client, key, ctx.obj) def _check_key_collision(client, key: str) -> str | None: """Check whether KEY-1 already resolves to an issue from a different project. Jira preserves a permanent key->issue redirect when a project is renamed, so an old, renamed-away key can be reused for a brand-new project, but the issue-numbering counter for that key still silently skips whatever numbers the old project already used. This is only detectable by probing KEY-1 before creation — Jira gives no warning otherwise. Returns a human-readable "FOUND-KEY (project NAME)" description if a collision is detected, else None. Never raises — a failure of this diagnostic-only check must not block project creation. """ try: result = client.jql(f'key = "{key}-1"', fields=["summary", "project"], limit=1) issues = result.get("issues", []) if not issues: return None found = issues[0] found_key = found.get("key", "?") found_project = found.get("fields", {}).get("project", {}).get("key", "?") return f"{found_key} (project {found_project})" except Exception: return None def _create_bootstrap_issues(client, project_key: str, ctx_obj: dict) -> None: """Create the XXX-1 convention issue (Config Hub / "Projektmanagement"). Matches the NR-wide "New project structure — first issue" convention. Uses this instance's dedicated "Issue Number One" issue type (purpose-built for exactly this "config hub" role) rather than a plain Task, with summary "Projektmanagement" per NR convention. Falls back to Task if a --from-project template's issue type scheme doesn't include it — issue type schemes vary per template and this issue existing at all matters more than its exact type. """ config_hub_description = "Mail-Handler-Adressen, Matrix-Webhook-URL und weitere Projekt-Einstellungen." def _config_hub_fields(issue_type: str) -> dict: return { "project": {"key": project_key}, "summary": "Projektmanagement", "issuetype": {"name": issue_type}, "description": config_hub_description, } def _report_created(issue_key: str) -> None: """Announce the bootstrap issue without corrupting machine-readable output. `success()` writes to stdout, so emitting it under `--json` or `--quiet` would append a `✓` line to the payload the caller is parsing. """ if ctx_obj.get("quiet") or ctx_obj.get("json"): return success(f"Created {issue_key}: Projektmanagement") try: result = client.create_issue(fields=_config_hub_fields("Issue Number One")) _report_created(result["key"]) except Exception as e: warning(f"'Issue Number One' type unavailable, falling back to Task: {e}") try: result = client.create_issue(fields=_config_hub_fields("Task")) _report_created(result["key"]) except Exception as e2: warning(f"Could not create bootstrap issue 'Projektmanagement': {e2}") if __name__ == "__main__": cli() -
jira-move.py 9.7 KB
#!/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 issue move - move issues between projects or change issue type.""" 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 import requests from lib.client import AuthenticationError, LazyJiraClient, SessionExpiredError, _sanitize_error from lib.output import error, format_output, success, 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 (just new issue key)") @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 issue move. Change issue type within the same project. Cross-project moves are intentionally refused by this command because they are not safely supported via the standard issue edit endpoint (some Jira versions ignore project changes without error). """ 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) @cli.command("issue") @click.argument("issue_key") @click.argument("target_project") @click.option("--issue-type", "-t", help="Issue type in target project (default: keep current)") @click.option("--dry-run", is_flag=True, help="Show what would happen without making changes") @click.pass_context def move_issue(ctx, issue_key: str, target_project: str, issue_type: str | None, dry_run: bool): """Change an issue's type within the same project. ISSUE_KEY: The Jira issue key to move (e.g., NRS-4301) TARGET_PROJECT: Target project key (e.g., SRVUC). Use the same project key to change issue type. Cross-project moves are refused. Examples: jira-move issue NRS-4301 PROJ --issue-type Task (change type, same project) jira-move issue NRS-4301 NRS --issue-type Task --dry-run """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: # Fetch current issue details issue = client.issue(issue_key, fields="summary,issuetype,status,project") fields = issue["fields"] current_project = fields["project"]["key"] current_type = fields["issuetype"]["name"] summary = fields["summary"] status = fields["status"]["name"] same_project = current_project.upper() == target_project.upper() if same_project and not issue_type: error(f"{issue_key} is already in project {target_project} (use --issue-type to change type)") sys.exit(1) target_type = issue_type or current_type if same_project and target_type == current_type: error(f"{issue_key} is already type {current_type} in project {target_project}") sys.exit(1) # IMPORTANT: Cross-project moves are NOT safely supported via the standard # issue edit endpoint. Refuse even for --dry-run so we never preview an # operation this command will not execute. if not same_project: error( "Cross-project move is not supported safely by this command yet. " "Refusing to proceed to avoid partial moves. " "Use the Jira UI Move action (or implement bulk move API support)." ) sys.exit(1) # Dry run (same project only — cross-project moves are refused above) if dry_run: warning("DRY RUN - No changes will be made") print(f"\nWould change type of {issue_key}:") print(f" Summary: {summary}") print(f" From: {current_type}") print(f" To: {target_type}") print(f" Status: {status}") return # Use the REST API directly — atlassian-python-api doesn't have a move method. # # IMPORTANT: Cross-project moves are NOT safely supported via the standard # issue edit endpoint. Some Jira Server/DC versions silently ignore # `project` updates, which looks like success but leaves the issue in the # old project with a changed issue type (data corruption). # PUT /rest/api/2/issue/{issueKey} with issuetype change (same project) update_fields = {"fields": {"issuetype": {"name": target_type}}} url = f"{client.url}/rest/api/2/issue/{issue_key}" # atlassian-python-api has no public method for issue move/edit. # Using _session directly is intentional; version range (>=3.41.0,<4) in # PEP 723 header guards against breaking changes across major versions. response = client._session.put(url, json=update_fields) if response.status_code == 204: # Verify the update actually applied (defense against silent failures) refreshed = client.issue(issue_key, fields="issuetype,project") refreshed_fields = refreshed.get("fields") or {} refreshed_type = (refreshed_fields.get("issuetype") or {}).get("name") refreshed_project = (refreshed_fields.get("project") or {}).get("key") if refreshed_project and refreshed_project.upper() != current_project.upper(): error( f"Move verification failed: issue ended up in unexpected project " f"{refreshed_project} (expected {current_project})" ) sys.exit(1) if refreshed_type and refreshed_type != target_type: error( f"Type change {current_type} → {target_type} was rejected by Jira's REST " f"edit endpoint: {issue_key} is still type {refreshed_type}.", suggestion=( "Jira's edit endpoint silently refuses some issue-type conversions — " "notably between Sub-Task types (e.g. Sub: Task → Sub: Bug) and between " "Sub-Task and standard types. No payload variation makes it work via " "REST. Use the Jira UI's 'Move' action on the issue instead." ), ) sys.exit(1) # Type change within same project — key stays the same if ctx.obj["quiet"]: print(issue_key) elif ctx.obj["json"]: format_output( { "key": issue_key, "old_type": current_type, "new_type": target_type, "project": current_project, "summary": summary, }, as_json=True, ) else: success(f"Changed {issue_key} type: {current_type} → {target_type}") print(f" Summary: {summary}") elif response.status_code == 400: # Common: issue type not available in target project detail = response.json() if response.headers.get("content-type", "").startswith("application/json") else {} errors = detail.get("errors", {}) error_msgs = detail.get("errorMessages", []) msg = "; ".join(error_msgs) if error_msgs else "; ".join(f"{k}: {v}" for k, v in errors.items()) error(f"Cannot move {issue_key} to {target_project}: {msg}") if "issuetype" in str(errors).lower() or "issue type" in msg.lower(): print("\nHint: Use --issue-type to specify a valid type in the target project") sys.exit(1) else: response.raise_for_status() except SessionExpiredError as e: if ctx.obj["debug"]: raise error(str(e)) sys.exit(1) except AuthenticationError as e: if ctx.obj["debug"]: raise error(str(e)) sys.exit(1) except requests.HTTPError as e: if ctx.obj["debug"]: raise error(f"Failed to move {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to move {issue_key}: {_sanitize_error(str(e))}") sys.exit(1) if __name__ == "__main__": cli() -
jira-sprint.py 8.1 KB
#!/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 sprint operations - list sprints and get sprint issues.""" 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 # ═══════════════════════════════════════════════════════════════════════════════ # 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 sprint operations. List sprints and get sprint issues from agile boards. """ 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) @cli.command("list") @click.argument("board_id", type=int) @click.option("--state", "-s", type=click.Choice(["active", "future", "closed"]), help="Filter by sprint state") @click.pass_context def list_sprints(ctx, board_id: int, state: str | None): """List sprints for a board. BOARD_ID: The Jira agile board ID Examples: jira-sprint list 42 jira-sprint list 42 --state active jira-sprint list 42 --state future --json """ client = ctx.obj["client"] try: # Get sprints using agile API params = {} if state: params["state"] = state sprints: list[dict] = [] start_at = 0 while True: page_params = dict(params) page_params["startAt"] = start_at response = client.get(f"rest/agile/1.0/board/{board_id}/sprint", params=page_params) or {} values = response.get("values", []) or [] sprints.extend(values) is_last = bool(response.get("isLast")) if is_last or not values: break start_at += len(values) if ctx.obj["json"]: format_output(sprints, as_json=True) elif ctx.obj["quiet"]: for s in sprints: print(s.get("id", "")) else: if not sprints: print(f"No sprints found for board {board_id}") if state: print(f" (filtered by state: {state})") else: print(f"Sprints for board {board_id}:\n") rows = [] for s in sprints: start = s.get("startDate", "")[:10] if s.get("startDate") else "-" end = s.get("endDate", "")[:10] if s.get("endDate") else "-" rows.append( { "ID": s.get("id", ""), "Name": s.get("name", ""), "State": s.get("state", ""), "Start": start, "End": end, } ) print(format_table(rows, ["ID", "Name", "State", "Start", "End"])) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get sprints for board {board_id}: {e}") sys.exit(1) @cli.command() @click.argument("sprint_id", type=int) @click.option("--fields", "-f", default="key,summary,status,assignee", help="Comma-separated fields to return") @click.pass_context def issues(ctx, sprint_id: int, fields: str): """Get issues in a sprint. SPRINT_ID: The sprint ID Examples: jira-sprint issues 123 jira-sprint issues 123 --fields key,summary,status,priority """ client = ctx.obj["client"] try: field_list = [f.strip() for f in fields.split(",")] # Get sprint issues using agile API response = client.get(f"rest/agile/1.0/sprint/{sprint_id}/issue", params={"fields": ",".join(field_list)}) issues_list = response.get("issues", []) if ctx.obj["json"]: format_output(issues_list, as_json=True) elif ctx.obj["quiet"]: for issue in issues_list: print(issue["key"]) else: if not issues_list: print(f"No issues in sprint {sprint_id}") else: print(f"Issues in sprint {sprint_id} ({len(issues_list)} total):\n") rows = [] for issue in issues_list: row = {"key": issue["key"]} issue_fields = issue.get("fields", {}) for f in field_list: if f == "key": continue value = issue_fields.get(f) if isinstance(value, dict): value = value.get("name") or value.get("displayName") or str(value) row[f] = value or "-" rows.append(row) columns = ["key"] + [f for f in field_list if f != "key"] print(format_table(rows, columns)) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get issues for sprint {sprint_id}: {e}") sys.exit(1) @cli.command() @click.argument("board_id", type=int) @click.pass_context def current(ctx, board_id: int): """Get the current active sprint for a board. BOARD_ID: The Jira agile board ID Examples: jira-sprint current 42 """ client = ctx.obj["client"] try: # Get active sprints (first page is enough for "current") response = client.get(f"rest/agile/1.0/board/{board_id}/sprint", params={"state": "active"}) or {} sprints = response.get("values", []) or [] if not sprints: print(f"No active sprint for board {board_id}") return sprint = sprints[0] # Get first active sprint if ctx.obj["json"]: format_output(sprint, as_json=True) elif ctx.obj["quiet"]: print(sprint.get("id", "")) else: print(f"Current sprint for board {board_id}:\n") print(f" ID: {sprint.get('id', '')}") print(f" Name: {sprint.get('name', '')}") print(f" Goal: {sprint.get('goal', '-')}") start = sprint.get("startDate", "")[:10] if sprint.get("startDate") else "-" end = sprint.get("endDate", "")[:10] if sprint.get("endDate") else "-" print(f" Start: {start}") print(f" End: {end}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get current sprint for board {board_id}: {e}") sys.exit(1) if __name__ == "__main__": cli() -
jira-transition.py 33.1 KB
#!/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 issue transitions - list available transitions and change issue status.""" import json 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.markup_cli import MarkupGates, guard_wiki_markup, markup_options from lib.output import error, format_output, format_table, success, warning from lib.users import check_mentions_cli # ═══════════════════════════════════════════════════════════════════════════════ # Helper Functions # ═══════════════════════════════════════════════════════════════════════════════ def _get_to_status(transition: dict) -> str: """Get target status name from transition, handling both Cloud and Server formats. Cloud returns: {'to': {'name': 'In Progress', ...}} Server/DC returns: {'to': 'In Progress'} """ to_value = transition.get("to", "") if isinstance(to_value, dict): return to_value.get("name", "") return str(to_value) def _normalize_transition_name(name: str) -> str: r"""Normalize a transition/status name for tolerant matching. Strips leading non-word noise (emoji, symbols, whitespace) and case-folds, so a user-supplied "Resolve" matches a Jira transition labelled "✅ Resolve". Uses ``\W`` (Unicode-aware) rather than an ASCII class so localized names (Cyrillic, Han, accented, …) are preserved instead of stripped to empty, and ``casefold()`` for correct Unicode case-insensitive comparison. """ return re.sub(r"^\W+", "", name or "", flags=re.UNICODE).strip().casefold() def fetch_transitions(client, issue_key: str) -> list[dict]: """Available transitions **with their screens**. The bare listing answers half the question: which transitions exist. The other half — what each one requires — lives in the screen, and only ``expand=transitions.fields`` returns it. Without the expansion a caller cannot tell "requires nothing" from "nobody asked", which is exactly the confusion that put twelve status changes into two tickets that needed two. Falls back to the unexpanded listing if the instance or library version does not support the expansion, so this degrades to the previous behaviour rather than failing. """ try: raw = client.get_issue_transitions_full(issue_key, expand="transitions.fields") if isinstance(raw, dict): transitions = raw.get("transitions") # An empty list is a successful answer -- "this issue offers no # transitions" -- not a reason to ask again. Retrying there turns a # legitimate empty result into an error whenever the second call # fails. if isinstance(transitions, list) and all(_usable_transition(t) for t in transitions): return transitions except Exception: # noqa: BLE001 - any API/library shape problem falls back pass return client.get_issue_transitions(issue_key) def _usable_transition(entry: object) -> bool: """Whether an expanded transition entry can be consumed as-is. Every caller does ``entry.get(...)`` and `required_fields` walks the field spec, so a malformed member raises *past* the fallback instead of using it — the failure this module's fetch exists to avoid. An absent ``fields`` key stays valid on purpose: that is what an unexpanded answer looks like, and callers already report it as `?`. Only a ``fields`` that is present and not a mapping of mappings is rejected. The transition id is deliberately not checked here. Rejecting the response over a missing id would fall back to ``get_issue_transitions``, which reads the same endpoint and does ``int(transition["id"])`` — so the id would only move the crash one layer down. `do` guards it where it is actually used. """ if not isinstance(entry, dict): return False if "fields" not in entry: return True spec = entry["fields"] return isinstance(spec, dict) and all(isinstance(v, dict) for v in spec.values()) def _contract_lines(matching: dict, required: list[str], spec_known: bool) -> list[str]: """What the selector resolved to, for whoever has to read the outcome. Both `do` paths render this from here because they drifted once: the caveat below was added to the dry run and not to the real one, so the run that actually changes a ticket was the run that said least about what it was doing. """ lines = [ f" Transition: {matching.get('name')} (id {matching.get('id')})", f" To status: {_get_to_status(matching)}", f" Requires: {(', '.join(required) or '-') if spec_known else '?'}", ] if not spec_known: lines.append( " The field spec was not returned, so required fields were NOT " "checked — `?` is not `-`. The transition may still be rejected " "for a field nothing here could see." ) return lines _TRANSITION_COLUMNS = ["ID", "Name", "To Status", "Requires", "Also accepts"] def _transition_rows(transitions: list[dict]) -> list[dict]: """One table row per transition, with its screen's contract. `-` would read as "requires nothing". Without the screen we do not know, and that is a different statement — the exact confusion `required_fields` warns about — so an unexpanded entry gets `?` in both columns. """ rows = [] for t in transitions: if "fields" in t: req = required_fields(t) optional = [f for f in settable_fields(t) if f not in req] requires = ", ".join(req) or "-" accepts = ", ".join(optional) or "-" else: requires = accepts = "?" rows.append( { "ID": t.get("id", ""), "Name": t.get("name", ""), "To Status": _get_to_status(t), "Requires": requires, "Also accepts": accepts, } ) return rows def _transition_footnotes(transitions: list[dict]) -> list[str]: """Notes qualifying the table above them. Both say the same kind of thing: read alone, the table looks more definite than it is. `?` is not "requires nothing", and a name or target shared by two transitions identifies neither. """ notes = [] if any("fields" not in t for t in transitions): notes.append("`?` means the field spec was not returned, not that the transition requires nothing.") dupes = _ambiguous_selectors(transitions) if dupes: notes.append( "Ambiguous by name or target: " + "; ".join(dupes) + ".\nSelect those by ID — the label and the target status do not identify them." ) return notes def _ambiguous_selectors(transitions: list[dict]) -> list[str]: """Human-readable notes for names or targets shared by >1 transition.""" notes = [] for key, label in (("name", "name"), ("to", "target")): seen: dict[str, list[dict]] = {} for t in transitions: # Normalize both sides the way find_matching_transition does. # Case-folding the target only would let `✅ Closed` and `✖ Closed` # be refused by `do` while `list` shows no ambiguity at all — the # reader would then have no way to learn why. raw_value = t.get("name", "") if key == "name" else _get_to_status(t) value = _normalize_transition_name(raw_value) if value: seen.setdefault(value, []).append(t) for value, group in seen.items(): if len(group) > 1: ids = ", ".join(f"{t.get('id')} ({t.get('name')})" for t in group) notes.append(f"{label} {value!r} → {ids}") return notes def _missing_hint(missing: list[str]) -> str: """What to do about the fields this transition declares and we did not send. `resolution` has its own flag; anything else goes through --fields-json, same shape as `jira-issue.py update`. """ flagged = [f for f in missing if f == "resolution"] other = [f for f in missing if f != "resolution"] parts = ["This is the transition's own screen talking, not a convention."] if flagged: parts.append("Pass --resolution <name> for `resolution`.") if other: example = json.dumps({other[0]: "<value>"}) parts.append( "Pass --fields-json for " + ", ".join(f"`{f}`" for f in other) + f", e.g. --fields-json '{example}'." ) parts.append("`list` shows the requirements per transition.") return " ".join(parts) def required_fields(transition: dict) -> list[str]: """Field keys this transition's screen marks required. Present only when the transitions were fetched with ``expand=transitions.fields``; an unexpanded transition yields ``[]``, which is indistinguishable from "requires nothing" — so callers that care must ask for the expansion rather than infer from a bare listing. """ return sorted(k for k, v in (transition.get("fields") or {}).items() if v.get("required")) def settable_fields(transition: dict) -> list[str]: """Every field key this transition's screen accepts, required or not.""" return sorted((transition.get("fields") or {}).keys()) def find_matching_transition(transitions: list[dict], status_name: str) -> tuple[dict | None, list[dict]]: """Resolve a user-supplied name or id to a transition, tolerating emoji prefixes. Tiers, first hit wins: (0) exact transition **id**; (1) exact case-insensitive on transition name or target status; (2) normalized equality (emoji/symbol prefix stripped); (3) unique normalized-substring match. Returns (match, candidates): match is the resolved transition or None; candidates lists the >1 transitions an ambiguous selector matched (empty otherwise), so the caller can report them. Tier 0 exists because a name is not always a usable selector: two transitions from one status can share a target (``✅ Done → Closed`` and ``✖ Close → Closed``, which differ in whether they require a resolution), and two can share a display name up to an emoji (``✅ QA → Resolved`` and ``❌ QA → Reopened``, which are opposite outcomes). The id from the transition listing is the only unambiguous handle, so accept it. Tier 1 collects *all* exact matches rather than returning the first. Silently picking one of two is how a ticket ends up Reopened when the reviewer meant Resolved — or Closed by the transition that asks for nothing, when the one that demands a resolution was the point. Both failures are above; the second leaves no trace in the status. """ selector = (status_name or "").strip() by_id = [t for t in transitions if str(t.get("id", "")) == selector] if by_id: return by_id[0], [] target = selector.casefold() exact = [t for t in transitions if t.get("name", "").casefold() == target or _get_to_status(t).casefold() == target] if len(exact) == 1: return exact[0], [] if len(exact) > 1: return None, exact norm_target = _normalize_transition_name(status_name) if norm_target: # Same rule one tier down: `QA` normalizes to the same string for both # `✅ QA → Resolved` and `❌ QA → Reopened`, so collect and report rather # than take the first. norm_exact = [ t for t in transitions if norm_target in ( _normalize_transition_name(t.get("name", "")), _normalize_transition_name(_get_to_status(t)), ) ] if len(norm_exact) == 1: return norm_exact[0], [] if len(norm_exact) > 1: return None, norm_exact substring = [ t for t in transitions if norm_target in _normalize_transition_name(t.get("name", "")) or norm_target in _normalize_transition_name(_get_to_status(t)) ] if len(substring) == 1: return substring[0], [] if len(substring) > 1: return None, substring return None, [] # ═══════════════════════════════════════════════════════════════════════════════ # 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 issue transitions. List available transitions and change issue status. """ 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) # Kept for callers that resolve the config themselves rather than through # the client - the render preview does. Without them guard_wiki_markup # reads None and previews against the DEFAULT profile, which is a # different tenant from the one this command is writing to. ctx.obj["env_file"] = env_file ctx.obj["profile"] = profile @cli.command("list") @click.argument("issue_key") @click.pass_context def list_transitions(ctx, issue_key: str): """List available transitions for an issue. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) Shows all valid status transitions from the issue's current state. Example: jira-transition list PROJ-123 """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] try: transitions = fetch_transitions(client, issue_key) if ctx.obj["json"]: format_output(transitions, as_json=True) elif ctx.obj["quiet"]: for t in transitions: print(t.get("name", "")) else: # Get current status issue = client.issue(issue_key, fields="status") current_status = issue["fields"]["status"]["name"] print(f"Available transitions for {issue_key}") print(f"Current status: {current_status}\n") if not transitions: print("No transitions available from this status") else: print(format_table(_transition_rows(transitions), _TRANSITION_COLUMNS)) for note in _transition_footnotes(transitions): print("\n" + note) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get transitions for {issue_key}: {e}") sys.exit(1) @cli.command("do") @click.argument("issue_key") @click.argument("status_name", metavar="TRANSITION") @click.option("--comment", "-c", help="Comment to add during transition") @click.option("--resolution", "-r", help="Resolution name (for closing transitions)") @click.option( "--fields-json", help="JSON string of additional fields the transition screen requires (e.g. summary) — same shape as `jira-issue.py update`", ) @click.option("--no-verify-mentions", is_flag=True, help="Skip [~username] mention verification in --comment") @markup_options @click.option("--dry-run", is_flag=True, help="Show what would happen without making changes") @click.pass_context def do_transition( ctx, issue_key: str, status_name: str, comment: str | None, resolution: str | None, fields_json: str | None, no_verify_mentions: bool, gates: MarkupGates, dry_run: bool, ): """Transition an issue to a new status. ISSUE_KEY: The Jira issue key (e.g., PROJ-123) TRANSITION: A transition id, a transition name, or a target status name. Prefer the id from `list`. A name or a target is not always a unique handle — one status can offer "✅ Done → Closed" beside "✖ Close → Closed", which differ in what they require — and an ambiguous selector is refused with its candidates rather than resolved to a guess. Examples: jira-transition do PROJ-123 341 jira-transition do PROJ-123 "In Progress" jira-transition do PROJ-123 "Done" --resolution Fixed jira-transition do PROJ-123 "Done" -c "Deployed to production" -r Fixed jira-transition do PROJ-123 "Close" --resolution Done --fields-json '{"summary": "Unchanged summary, resubmitted because the screen demands it"}' jira-transition do PROJ-123 "In Review" --dry-run """ extra_fields: dict = {} if fields_json: try: extra_fields = json.loads(fields_json) except json.JSONDecodeError as e: error(f"Invalid JSON in --fields-json: {e}") sys.exit(1) # json.loads returns whatever the document says. A list, string, number # or null reaches set()/dict() below and fails there instead, as a # transition error that points at Jira rather than at the argument — # and `[]` passes through as no fields at all. if not isinstance(extra_fields, dict): error(f"--fields-json must be a JSON object, got {type(extra_fields).__name__}: {fields_json}") sys.exit(1) ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] # A transition comment is a real issue comment — same gates as jira-comment add. # Runs under --dry-run too, so the preview prints the text that would actually # be posted; only the render call is dropped, because that one talks to the # instance. Every guarded command follows this rule. comment = guard_wiki_markup( comment, gates=gates.offline() if dry_run else gates, issue_key=issue_key, env_file=ctx.obj.get("env_file"), profile=ctx.obj.get("profile"), label="transition comment", ) if not dry_run: check_mentions_cli(client, comment, skip=no_verify_mentions) try: # With their screens: what each transition requires is half the answer, # and a bare listing cannot express it. transitions = fetch_transitions(client, issue_key) # Find matching transition (id → exact → emoji-tolerant → unique substring) matching, ambiguous = find_matching_transition(transitions, status_name) if not matching: if ambiguous: rows = ", ".join(f"{t.get('id')} {t.get('name')} → {_get_to_status(t)}" for t in ambiguous) error(f"Transition '{status_name}' is ambiguous for {issue_key}") # Not "these lead to different places": the case that motivated # this is two transitions with the SAME target that differ in # what their screens require. Saying they diverge by destination # sends the reader to compare the one column where they agree. print( f"\nMatches: {rows}\n" "These are distinct transitions and may differ in what they require, " "even where they share a target. Pass the transition ID, not the name." ) else: available = ", ".join(f"{t.get('id')} {t.get('name')}" for t in transitions) error(f"Transition '{status_name}' not available for {issue_key}") print(f"\nAvailable transitions: {available}") sys.exit(1) # The id is what gets posted, so an entry without one cannot be acted on # by either path. Formatting it anyway sends {"id": "None"} and the API # answers with a rejection that says nothing about where the None came # from -- and a dry run would print `(id None)` and exit 0, which is the # worse of the two: it reports as safe something that cannot run. # Checked before either branch, because putting it in one of them is how # the contract and its caveat came to disagree earlier on this branch. transition_id = matching.get("id") if not transition_id: error(f"Transition '{matching.get('name')}' for {issue_key} has no id") print( "\nThe id is the only handle a transition is posted with, and this entry " "carried none. Run `list` to see what the server offers for this issue." ) sys.exit(1) # Without the screen we do not know what this transition wants, and # saying "-" would claim it wants nothing — the same conflation `list` # avoids with `?`. Skip the pre-check and say so, rather than implying a # check happened. spec_known = "fields" in matching required = required_fields(matching) supplied = ({"resolution"} if resolution else set()) | set(extra_fields) missing = [f for f in required if f not in supplied] if spec_known else [] # Dry run if dry_run: warning("DRY RUN - No transition will be performed") print(f"\nWould transition {issue_key}:") for line in _contract_lines(matching, required, spec_known): print(line) if comment: print(f" Comment: {comment}") if resolution: print(f" Resolution: {resolution}") if extra_fields: print(f" Fields: {extra_fields}") if missing: error("Missing required field(s) for this transition: " + ", ".join(missing)) print(" " + _missing_hint(missing)) sys.exit(1) return if missing: error(f"Transition '{matching['name']}' requires: {', '.join(missing)}") print("\n" + _missing_hint(missing)) sys.exit(1) # The contract this resolved to, on the path that actually changes the # ticket -- not only under --dry-run. When the POST is rejected for a # field, this is what tells the reader whether it was even checked. if not ctx.obj["quiet"] and not ctx.obj["json"]: print(f"Transitioning {issue_key}:") for line in _contract_lines(matching, required, spec_known): print(line) # Build transition payload fields = dict(extra_fields) if resolution: fields["resolution"] = {"name": resolution} # Post the transition by ID. Going via the target status name would let # the API re-resolve it, and a target is not always unique: one ticket # can offer `✅ Done → Closed` and `✖ Close → Closed`, which differ in # what they require. The ID is the only handle that means one thing. payload: dict = {"transition": {"id": str(transition_id)}} if fields: payload["fields"] = fields if comment: payload["update"] = {"comment": [{"add": {"body": comment}}]} client.post(f"rest/api/2/issue/{issue_key}/transitions", data=payload) if ctx.obj["quiet"]: print(issue_key) elif ctx.obj["json"]: format_output( {"key": issue_key, "transition": matching["name"], "to_status": _get_to_status(matching)}, as_json=True ) else: success(f"Transitioned {issue_key}") print(f" Status: {_get_to_status(matching)}") if comment: if len(comment) > 50: print(f" Comment added: {comment[:50]}...") else: print(f" Comment added: {comment}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to transition {issue_key}: {e}") sys.exit(1) # Transition names/targets that move an issue *backwards* (or out of the # forward flow). Skipped when the walker auto-picks the next step so a linear # workflow doesn't bounce back toward where it came from. # Matched as substrings so inflected forms are caught ("reopen" -> "Reopened", # "cancel" -> "Cancelled", "reject" -> "Rejected"). _BACKWARD_SUBSTRINGS = ("reopen", "cancel", "reject", "decline", "abort") # "back" is matched as a whole word only: "Move back" counts, but "Backlog", # "Rollback" and "Feedback" must not be mistaken for backward transitions. _BACKWARD_WORD_RE = re.compile(r"\bback\b") def _is_backward(transition: dict, visited: set[str]) -> bool: """True if a transition leads backward: its name matches a backward verb, or its target status was already visited (would loop).""" name = (transition.get("name") or "").lower() if any(word in name for word in _BACKWARD_SUBSTRINGS) or _BACKWARD_WORD_RE.search(name): return True return _get_to_status(transition).lower() in visited @cli.command("path") @click.argument("issue_key") @click.argument("target_status") @click.option("--resolution", "-r", help="Resolution applied on the final transition") @click.option("--comment", "-c", help="Comment added on the final transition") @click.option( "--max-steps", type=click.IntRange(min=1), default=10, show_default=True, help="Safety cap on transitions walked" ) @click.option("--no-verify-mentions", is_flag=True, help="Skip [~username] mention verification in --comment") @markup_options @click.option("--dry-run", is_flag=True, help="Show the first planned step without transitioning") @click.pass_context def path_transition( ctx, issue_key: str, target_status: str, resolution: str | None, comment: str | None, max_steps: int, no_verify_mentions: bool, gates: MarkupGates, dry_run: bool, ): """Walk the workflow from the current status to TARGET_STATUS. Runs the list -> pick -> do loop internally, collapsing a multi-stage transition chain (e.g. QA -> UAT -> Resolved -> Closed) into one command. The Jira API only exposes the transitions available from the issue's *current* status, so the walk is greedy, not a full graph search: at each step it takes TARGET_STATUS if directly reachable, otherwise the single non-backward transition. If a step is ambiguous (several forward options) it stops and lists them so you can pick with `do`. --resolution/--comment apply only to the final transition. Examples: jira-transition path PROJ-123 Closed --resolution Done jira-transition path PROJ-123 "Ready for deployment" --dry-run """ ctx.obj["client"].with_context(issue_key=issue_key) client = ctx.obj["client"] quiet, as_json = ctx.obj["quiet"], ctx.obj["json"] try: issue = client.issue(issue_key, fields="status") current = issue["fields"]["status"]["name"] target_l = target_status.lower() visited = {current.lower()} chain: list[str] = [] if current.lower() == target_l: if as_json: format_output({"key": issue_key, "status": current, "steps": []}, as_json=True) elif quiet: print(issue_key) else: success(f"{issue_key} is already in status '{current}' - nothing to do") return # The comment rides on the FINAL transition, but it is gated here, before # the first one: a walk that aborts halfway has already moved the issue, # and the status it stopped in is not one anybody chose. Below the # already-in-target return, so the no-op case does not pay for a render # call. A walk that stops at an ambiguous first step still does - it # cannot be known to be ambiguous until the transitions are fetched. comment = guard_wiki_markup( comment, gates=gates.offline() if dry_run else gates, issue_key=issue_key, env_file=ctx.obj.get("env_file"), profile=ctx.obj.get("profile"), label="transition comment", ) if not dry_run: check_mentions_cli(client, comment, skip=no_verify_mentions) for _ in range(max_steps): transitions = client.get_issue_transitions(issue_key) # Prefer a transition landing directly on the target. chosen = next((t for t in transitions if _get_to_status(t).lower() == target_l), None) is_final = chosen is not None if chosen is None: forward = [t for t in transitions if not _is_backward(t, visited)] if len(forward) != 1: options = ", ".join(f"{t.get('name') or ''} -> {_get_to_status(t)}" for t in transitions) or "none" reason = "no forward transition available" if not forward else "ambiguous next step" error( f"Cannot auto-advance {issue_key} from '{current}' toward '{target_status}': {reason}", suggestion=f"Available transitions: {options}. " f"Pick one explicitly with: jira-transition do {issue_key} <STATUS>", ) sys.exit(1) chosen = forward[0] to_status = _get_to_status(chosen) if dry_run: warning("DRY RUN - No transition will be performed") print(f"\nNext step for {issue_key}: {chosen.get('name', '')} -> {to_status}") print(f"Current: {current} | Target: {target_status}") if comment: # Named as belonging to the final transition, which is not # the step shown above: the walk is greedy and only the last # hop carries the comment. print(f"Comment (on the final transition): {comment}") if not is_final: print("(walk continues greedily from there; re-run without --dry-run to execute)") return fields = {"resolution": {"name": resolution}} if (resolution and is_final) else {} update = {"comment": [{"add": {"body": comment}}]} if (comment and is_final) else None # By id, for the same reason `do` is. set_issue_status() re-resolves # the target through get_transition_id_to_status_name(), which # returns the FIRST transition whose target matches the name -- so # the walker's own choice is discarded wherever two transitions # share a target, which is exactly where the choice mattered. It # also returns None when nothing matches, posting a null id, and # spends an extra round-trip re-fetching what `chosen` already holds. # # No id guard here, unlike `do`: these come from # get_issue_transitions(), which builds every entry with # int(transition["id"]) and so cannot hand out one without an id. payload: dict = {"transition": {"id": str(chosen["id"])}} if fields: payload["fields"] = fields if update: payload["update"] = update client.post(f"rest/api/2/issue/{issue_key}/transitions", data=payload) chain.append(to_status) visited.add(to_status.lower()) current = to_status if is_final: break else: error(f"Reached --max-steps ({max_steps}) before arriving at '{target_status}' (now at '{current}')") sys.exit(1) if as_json: format_output({"key": issue_key, "status": current, "steps": chain}, as_json=True) elif quiet: print(issue_key) else: success(f"Transitioned {issue_key} to '{current}'") print(f" Path: {' -> '.join(chain)}") if resolution: print(f" Resolution: {resolution}") except SystemExit: raise except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to walk {issue_key} to '{target_status}': {e}") sys.exit(1) if __name__ == "__main__": cli() -
jira-version.py 29.3 KB
#!/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 project version operations - list, get, create, update, release lifecycle, move, merge, delete.""" import sys from datetime import date as _date 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 AuthenticationError, LazyJiraClient, SessionExpiredError from lib.output import error, format_output, format_table, success, warning # ═══════════════════════════════════════════════════════════════════════════════ # Helpers # ═══════════════════════════════════════════════════════════════════════════════ def _validate_iso_date(s: str) -> str: """Validate an ISO date string (YYYY-MM-DD) and return it unchanged. Jira rejects full timestamps like `2026-05-31T00:00:00Z` with a 400 on version start/release dates. This helper enforces the date-only shape client-side with a clear error. ``date.fromisoformat`` (Python 3.10) accepts only the strict ``YYYY-MM-DD`` shape, which is exactly what Jira requires. """ if not isinstance(s, str): raise click.BadParameter(f'Expected YYYY-MM-DD, got "{s}". Timestamps are not allowed.') try: _date.fromisoformat(s) except ValueError as e: raise click.BadParameter(f'Expected YYYY-MM-DD, got "{s}" ({e}).') from e return s def _validate_numeric_id(value: str, label: str = "version ID") -> str: """Reject non-numeric IDs so they cannot be interpolated into REST paths. The Jira REST surface treats version IDs as positional path segments (``/version/{id}``, ``/version/{src}/mergeto/{dst}``). A non-numeric value such as ``../../issue/KEY`` would otherwise traverse to a different resource. All callers feeding user input into a path must validate first. """ if not isinstance(value, str) or not value.isdigit(): raise click.BadParameter(f'{label} must be numeric, got "{value}".') return value def _status_of(v: dict) -> str: if v.get("archived"): return "archived" return "released" if v.get("released") else "unreleased" def _fmt_list_row(v: dict) -> dict: return { "ID": v.get("id", ""), "NAME": v.get("name", ""), "STATUS": _status_of(v), "START": v.get("startDate", "-") or "-", "RELEASE": v.get("releaseDate", "-") or "-", "ISSUES": v.get("issueCount", "-") if v.get("issueCount") is not None else "-", } def _is_numeric_id(s: str) -> bool: return s.isdigit() def _render_version(v: dict, counts: dict | None = None) -> str: lines = [ f"Version {v.get('id', '?')} — {v.get('name', '')}", f" Project: {v.get('project', v.get('projectId', ''))}", f" Status: {_status_of(v)}", f" Start: {v.get('startDate', '-') or '-'}", f" Release: {v.get('releaseDate', '-') or '-'}", ] if v.get("description"): lines.append(f" Description: {v['description']}") if counts: fixed = counts.get("issuesFixedCount", counts.get("fixed", "?")) affected = counts.get("issuesAffectedCount", counts.get("affected", "?")) unresolved = counts.get("issuesUnresolvedCount", counts.get("unresolved", "?")) lines.append(f" Issues: fixed={fixed} affected={affected} unresolved={unresolved}") return "\n".join(lines) # ═══════════════════════════════════════════════════════════════════════════════ # 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 project version operations. Manage the release lifecycle: list, create, release, archive, merge, delete versions. """ 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) @cli.command("list") @click.argument("project_key") @click.option( "--status", type=click.Choice(["released", "unreleased", "archived", "all"]), default="unreleased", help="Filter by status", ) @click.option("--query", help="Filter by name substring (paginated endpoint)") @click.option( "--order-by", type=click.Choice(["sequence", "name", "startDate", "releaseDate"]), help="Sort order (paginated endpoint)", ) @click.pass_context def list_versions(ctx, project_key: str, status: str, query: str | None, order_by: str | None): """List versions in a project. Uses the flat `/project/{key}/versions` endpoint unless --query or --order-by is provided, in which case it switches to the paginated endpoint. """ client = ctx.obj["client"] try: if query or order_by: try: versions = _fetch_versions_paginated(client, project_key, status=status, query=query, order_by=order_by) except Exception as paginated_err: # Older Jira DC (<9.x) returns 404 for /project/{key}/version. # Fall back to the flat endpoint and apply --query / --order-by # client-side so the user still gets a useful result. resp_status = getattr(getattr(paginated_err, "response", None), "status_code", None) if resp_status != 404: raise warning( "Paginated /project/{key}/version endpoint returned 404; " "falling back to flat endpoint with client-side filter/sort." ) versions = client.get(f"rest/api/2/project/{project_key}/versions") or [] if query: q = query.lower() versions = [v for v in versions if q in (v.get("name", "") or "").lower()] if order_by: versions = sorted(versions, key=lambda v: (v.get(order_by) is None, v.get(order_by) or "")) # Server-side `status` param is DC ≥9.x only; apply a client-side # safety filter so older servers don't silently return all statuses. if status != "all": versions = [v for v in versions if _status_of(v) == status] else: versions = client.get(f"rest/api/2/project/{project_key}/versions") or [] if status != "all": versions = [v for v in versions if _status_of(v) == status] if ctx.obj["json"]: format_output(versions, as_json=True) return if ctx.obj["quiet"]: for v in versions: print(v.get("id", "")) return print(f"{status.capitalize()} versions in {project_key} ({len(versions)}):\n") rows = [_fmt_list_row(v) for v in versions] print(format_table(rows, columns=["ID", "NAME", "STATUS", "START", "RELEASE", "ISSUES"])) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to list versions: {e}") sys.exit(1) def _fetch_versions_paginated(client, project_key, status=None, query=None, order_by=None): """Paginated search via `/project/{key}/version`. Availability: Jira Server/DC >=9.x and Jira Cloud. Older DC versions return 404; ``list_versions`` catches that and falls back to the flat `/project/{key}/versions` endpoint with client-side filter and sort so the user still gets a useful result. """ all_values: list[dict] = [] start_at = 0 page_size = 50 while True: params = {"startAt": start_at, "maxResults": page_size} if query: params["query"] = query if order_by: params["orderBy"] = order_by if status and status != "all": params["status"] = status page = client.get(f"rest/api/2/project/{project_key}/version", params=params) or {} values = page.get("values", []) all_values.extend(values) if page.get("isLast", True) or not values: break start_at += len(values) or page_size return all_values @cli.command() @click.argument("version") @click.option("--project", help="Required when VERSION is a name (not an ID)") @click.option("--counts", is_flag=True, help="Include fixed/affected/unresolved issue counts") @click.pass_context def get(ctx, version: str, project: str | None, counts: bool): """Get a single version by ID or name.""" client = ctx.obj["client"] try: if _is_numeric_id(version): v = client.get(f"rest/api/2/version/{version}") else: v = _resolve_version_by_name(client, version, project) # implemented in Task 6 extra = None if counts: extra = _fetch_counts(client, v["id"]) # implemented in Task 7 if ctx.obj["json"]: payload = dict(v) if extra: payload["_counts"] = extra format_output(payload, as_json=True) return if ctx.obj["quiet"]: print(v.get("id", "")) return print(_render_version(v, counts=extra)) except SystemExit: raise except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to get version: {e}") sys.exit(1) def _resolve_version_by_name(client, name: str, project_key: str | None) -> dict: if not project_key: error("--project is required when looking up a version by name") sys.exit(2) versions = client.get(f"rest/api/2/project/{project_key}/versions") or [] matches = [v for v in versions if v.get("name") == name] if not matches: error(f'No version named "{name}" found in {project_key}') sys.exit(1) if len(matches) > 1: ids = ", ".join(v.get("id", "?") for v in matches) error(f'Multiple versions named "{name}" in {project_key} (ids: {ids})') sys.exit(1) return matches[0] def _fetch_counts(client, vid: str) -> dict: related = client.get(f"rest/api/2/version/{vid}/relatedIssueCounts") or {} unresolved = client.get(f"rest/api/2/version/{vid}/unresolvedIssueCount") or {} return {**related, **unresolved} # Sentinel distinguishes "caller did not provide this key" from "caller passed None to clear". _UNSET = object() def _safe_update_version(client, vid: str, **patch) -> dict: """Safely update a version by GET + dict-merge + PUT. Why: Jira's `PUT /version/{id}` is treated as *replace* on some Server/DC deployments (and a few Cloud tenants), meaning any field omitted from the body is cleared. To avoid accidentally wiping `description`, `startDate`, etc. when the user only wants to update one field, we always fetch the current version first and merge the caller's patch onto it before PUTting. Any kwarg whose value is not the _UNSET sentinel is applied verbatim. Pass an explicit ``None`` to clear a field (e.g. `releaseDate=None` on unrelease); the null is preserved in the PUT body. """ current = client.get(f"rest/api/2/version/{vid}") or {} merged = dict(current) for key, value in patch.items(): if value is _UNSET: continue merged[key] = value # Strip server-managed fields we shouldn't echo back. # # userReleaseDate / userStartDate are display-only, locale-formatted # mirrors of releaseDate / startDate that Jira Server/DC includes in the # GET response (e.g. "06/Mai/26"). Echoing them back alongside the ISO # releaseDate / startDate trips the API's mutually-exclusive validation: # "Only one of 'releaseDate' and 'userReleaseDate' can be specified # when editing a version." # Jira regenerates them from the ISO dates on read, so dropping them is safe. for ro in ("self", "operations", "projectId", "userReleaseDate", "userStartDate"): merged.pop(ro, None) return client.put(f"rest/api/2/version/{vid}", data=merged) def _emit_mutation_result(ctx, payload: dict, *, fallback_id: str, success_msg: str) -> None: """Render the result of a mutating subcommand honouring --json / --quiet. Why: without this, `release` / `archive` / `move` / `merge` / `delete` always print the ``✓ …`` success line, breaking `--quiet` pipelines and emitting non-JSON on `--json`. ``payload`` is the API response (may be empty); when empty we fall back to an id-only JSON object so consumers always get a structured result. """ if ctx.obj.get("json"): data = dict(payload) if payload else {"id": fallback_id} format_output(data, as_json=True) return if ctx.obj.get("quiet"): vid = (payload or {}).get("id") or fallback_id print(vid) return success(success_msg) def _version_self_url(client, vid: str) -> str: """Build a fully-qualified self URL for a version from the client's base URL. Used by `move --after OTHER_ID` where the Jira API expects a `self` URL rather than a bare ID. Constructed from the configured Jira base URL (never from user input) to avoid SSRF or cross-instance spoofing. """ base = getattr(client, "url", "") or "" if not base: raise RuntimeError("Jira client has no configured URL; cannot build self URL") base = base.rstrip("/") return f"{base}/rest/api/2/version/{vid}" @cli.command() @click.argument("project_key") @click.argument("name") @click.option("--description", help="Version description (plain text or wiki markup)") @click.option("--start-date", help="Start date YYYY-MM-DD") @click.option("--release-date", help="Release date YYYY-MM-DD") @click.option("--released", is_flag=True, help="Mark as released on creation") @click.option("--archived", is_flag=True, help="Mark as archived on creation") @click.option("--dry-run", is_flag=True, help="Show what would be created") @click.pass_context def create(ctx, project_key, name, description, start_date, release_date, released, archived, dry_run): """Create a new version in a project.""" client = ctx.obj["client"] payload = {"name": name, "project": project_key, "released": released, "archived": archived} if description: payload["description"] = description if start_date: payload["startDate"] = _validate_iso_date(start_date) if release_date: payload["releaseDate"] = _validate_iso_date(release_date) if dry_run: warning("DRY RUN - No version will be created") print(f"Would POST rest/api/2/version with:\n {payload}") return try: created = client.post("rest/api/2/version", data=payload) vid = (created or {}).get("id", "?") if ctx.obj["json"]: format_output(created, as_json=True) elif ctx.obj["quiet"]: print(vid) else: extra = f" (release {release_date})" if release_date else "" success(f'Created version {vid} "{name}" in {project_key}{extra}') except SessionExpiredError as e: if ctx.obj["debug"]: raise error(str(e)) sys.exit(1) except AuthenticationError as e: if ctx.obj["debug"]: raise error(str(e)) sys.exit(1) except Exception as e: if ctx.obj["debug"]: raise status = getattr(getattr(e, "response", None), "status_code", None) if status == 409: error(f'Version "{name}" already exists in {project_key}') else: error(f"Failed to create version: {e}") sys.exit(1) @cli.command() @click.argument("version_id") @click.option("--name", help="New name") @click.option("--description", help="New description") @click.option("--start-date", help="New start date YYYY-MM-DD") @click.option("--release-date", help="New release date YYYY-MM-DD") @click.option("--released/--unreleased", default=None, help="Mark released or unreleased") @click.option("--archived/--unarchived", default=None, help="Mark archived or unarchived") @click.option("--dry-run", is_flag=True, help="Show what would be updated") @click.pass_context def update(ctx, version_id, name, description, start_date, release_date, released, archived, dry_run): """Update fields on an existing version (safe-merge: GET + merge + PUT).""" _validate_numeric_id(version_id) client = ctx.obj["client"] patch: dict = {} if name is not None: patch["name"] = name if description is not None: patch["description"] = description if start_date is not None: patch["startDate"] = _validate_iso_date(start_date) if release_date is not None: patch["releaseDate"] = _validate_iso_date(release_date) if released is True: patch["released"] = True elif released is False: # --unreleased: clear releaseDate unless caller also set --release-date patch["released"] = False patch.setdefault("releaseDate", None) if archived is True: patch["archived"] = True elif archived is False: patch["archived"] = False if not patch: error( "No fields to update. Provide at least one of --name / --description / --start-date / --release-date / --released/--unreleased / --archived/--unarchived" ) sys.exit(2) if dry_run: warning("DRY RUN - No version will be updated") print(f"Would PUT rest/api/2/version/{version_id} with patch:\n {patch}") return try: _safe_update_version(client, version_id, **patch) if ctx.obj["json"]: format_output({"id": version_id, "updated": True, "patch": patch}, as_json=True) elif ctx.obj["quiet"]: print("ok") else: success(f"Updated version {version_id}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to update version: {e}") sys.exit(1) @cli.command() @click.argument("version_id") @click.option("--release-date", help="Release date YYYY-MM-DD (default: today)") @click.option("--dry-run", is_flag=True, help="Show what would change") @click.pass_context def release(ctx, version_id, release_date, dry_run): """Mark a version released (sets released=true + releaseDate).""" _validate_numeric_id(version_id) rdate = _validate_iso_date(release_date) if release_date else _date.today().isoformat() patch = {"released": True, "releaseDate": rdate} if dry_run: warning("DRY RUN - No version will be updated") print(f"Would release {version_id} on {rdate}") return client = ctx.obj["client"] try: updated = _safe_update_version(client, version_id, **patch) or {} _emit_mutation_result( ctx, updated, fallback_id=version_id, success_msg=f"Released version {version_id} on {rdate}", ) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to release version: {e}") sys.exit(1) @cli.command() @click.argument("version_id") @click.option("--dry-run", is_flag=True, help="Show what would change") @click.pass_context def unrelease(ctx, version_id, dry_run): """Mark a version unreleased (clears releaseDate).""" _validate_numeric_id(version_id) patch = {"released": False, "releaseDate": None} if dry_run: warning("DRY RUN - No version will be updated") print(f"Would unrelease {version_id} (releaseDate cleared)") return client = ctx.obj["client"] try: updated = _safe_update_version(client, version_id, **patch) or {} _emit_mutation_result( ctx, updated, fallback_id=version_id, success_msg=f"Unreleased version {version_id}", ) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to unrelease version: {e}") sys.exit(1) @cli.command() @click.argument("version_id") @click.option("--dry-run", is_flag=True, help="Show what would change") @click.pass_context def archive(ctx, version_id, dry_run): """Archive a version (hides it from pickers).""" _validate_numeric_id(version_id) if dry_run: warning("DRY RUN - No version will be updated") print(f"Would archive {version_id}") return client = ctx.obj["client"] try: updated = _safe_update_version(client, version_id, archived=True) or {} _emit_mutation_result( ctx, updated, fallback_id=version_id, success_msg=f"Archived version {version_id}", ) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to archive version: {e}") sys.exit(1) @cli.command() @click.argument("version_id") @click.option("--dry-run", is_flag=True, help="Show what would change") @click.pass_context def unarchive(ctx, version_id, dry_run): """Unarchive a version.""" _validate_numeric_id(version_id) if dry_run: warning("DRY RUN - No version will be updated") print(f"Would unarchive {version_id}") return client = ctx.obj["client"] try: updated = _safe_update_version(client, version_id, archived=False) or {} _emit_mutation_result( ctx, updated, fallback_id=version_id, success_msg=f"Unarchived version {version_id}", ) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to unarchive version: {e}") sys.exit(1) @cli.command() @click.argument("version_id") @click.option("--after", help="Move this version to directly after the given version ID") @click.option( "--position", type=click.Choice(["First", "Last", "Earlier", "Later"]), help="Move relative to current position" ) @click.option("--dry-run", is_flag=True, help="Show what would change") @click.pass_context def move(ctx, version_id, after, position, dry_run): """Reorder a version within its project.""" _validate_numeric_id(version_id) if bool(after) == bool(position): error("Provide exactly one of --after or --position") sys.exit(2) client = ctx.obj["client"] if after: if not _is_numeric_id(after): error(f'--after expects a numeric version ID, got "{after}"') sys.exit(2) body = {"after": _version_self_url(client, after)} else: body = {"position": position} if dry_run: warning("DRY RUN - No version will be moved") print(f"Would POST rest/api/2/version/{version_id}/move with:\n {body}") return try: resp = client.post(f"rest/api/2/version/{version_id}/move", data=body) or {} msg = f"Moved version {version_id} after {after}" if after else f"Moved version {version_id} to {position}" _emit_mutation_result(ctx, resp, fallback_id=version_id, success_msg=msg) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to move version: {e}") sys.exit(1) @cli.command() @click.argument("src_id") @click.argument("into", type=click.Choice(["INTO"])) @click.argument("dst_id") @click.option("--dry-run", is_flag=True, help="Show what would change without calling mergeto") @click.pass_context def merge(ctx, src_id, into, dst_id, dry_run): """Merge SRC_ID INTO DST_ID. Reassigns fixVersions / versions references from SRC to DST, then deletes SRC server-side. There is no undo. """ _validate_numeric_id(src_id, label="SRC_ID") _validate_numeric_id(dst_id, label="DST_ID") client = ctx.obj["client"] if dry_run: warning("DRY RUN - No changes will be made") try: counts = client.get(f"rest/api/2/version/{src_id}/relatedIssueCounts") or {} src = client.get(f"rest/api/2/version/{src_id}") or {} dst = client.get(f"rest/api/2/version/{dst_id}") or {} except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to preview merge: {e}") sys.exit(1) fixed = counts.get("issuesFixedCount", "?") affected = counts.get("issuesAffectedCount", "?") print(f'Would merge {src_id} "{src.get("name", "?")}" INTO {dst_id} "{dst.get("name", "?")}":') print(f" fixed issues to reassign: {fixed}") print(f" affected issues to reassign: {affected}") print(" source version would be deleted") return try: client.post(f"rest/api/2/version/{src_id}/mergeto/{dst_id}") if ctx.obj.get("json"): format_output({"src": src_id, "dst": dst_id, "merged": True}, as_json=True) elif ctx.obj.get("quiet"): print(dst_id) else: success(f"Merged {src_id} into {dst_id}; source deleted") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to merge version: {e}") sys.exit(1) @cli.command() @click.argument("version_id") @click.option("--move-fix-to", help="Reassign fixVersions refs to this version ID") @click.option("--move-affected-to", help="Reassign affectsVersions refs to this version ID") @click.option("--dry-run", is_flag=True, help="Show what would be deleted") @click.pass_context def delete(ctx, version_id, move_fix_to, move_affected_to, dry_run): """Delete a version, optionally reassigning fixVersions/versions refs.""" _validate_numeric_id(version_id) client = ctx.obj["client"] if dry_run: try: v = client.get(f"rest/api/2/version/{version_id}") or {} counts = client.get(f"rest/api/2/version/{version_id}/relatedIssueCounts") or {} except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to preview delete: {e}") sys.exit(1) warning("DRY RUN - No version will be deleted") fixed = counts.get("issuesFixedCount", "?") affected = counts.get("issuesAffectedCount", "?") print(f'Would delete {version_id} "{v.get("name", "?")}":') print(f" fixVersion refs: {fixed}" + (f" → {move_fix_to}" if move_fix_to else " (would be orphaned)")) print( f" affectsVersion refs: {affected}" + (f" → {move_affected_to}" if move_affected_to else " (would be orphaned)") ) return # non-dry-run for flag, val in (("--move-fix-to", move_fix_to), ("--move-affected-to", move_affected_to)): if val and not _is_numeric_id(val): error(f'{flag} expects a numeric version ID, got "{val}"') sys.exit(2) params: dict = {} if move_fix_to: params["moveFixIssuesTo"] = move_fix_to if move_affected_to: params["moveAffectedIssuesTo"] = move_affected_to if not move_fix_to and not move_affected_to: warning( "No --move-fix-to / --move-affected-to provided. " "fixVersions/versions references on existing issues will be orphaned." ) try: client.delete(f"rest/api/2/version/{version_id}", params=params or None) if ctx.obj.get("json"): payload = {"id": version_id, "deleted": True} if move_fix_to: payload["moveFixIssuesTo"] = move_fix_to if move_affected_to: payload["moveAffectedIssuesTo"] = move_affected_to format_output(payload, as_json=True) elif ctx.obj.get("quiet"): print(version_id) else: parts = [] if move_fix_to: parts.append(f"fixVersion refs reassigned to {move_fix_to}") if move_affected_to: parts.append(f"affectsVersion refs reassigned to {move_affected_to}") detail = "; " + "; ".join(parts) if parts else "" success(f"Deleted version {version_id}{detail}") except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to delete version: {e}") sys.exit(1) if __name__ == "__main__": cli() -
tempo-account.py 8 KB
#!/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", # ] # /// """Tempo Accounts (Server/DC) - create customers/accounts and link them to projects. Tempo Accounts is a Jira plugin (rest/tempo-accounts/1/*), reachable with the same Jira Personal Access Token this skill already uses elsewhere - no separate credential. Requires the Tempo "Manage Accounts" permission, which is independent of plain Jira project permissions. """ 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, success, 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 (just the created key/id)") @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): """Tempo Accounts management. Create Tempo customers/accounts and link an account to a Jira project. """ 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) @cli.group() def customer(): """Tempo customer (billing entity) management.""" @customer.command("create") @click.argument("key") @click.argument("name") @click.option("--dry-run", is_flag=True, help="Show what would be created without making changes") @click.pass_context def customer_create(ctx, key: str, name: str, dry_run: bool): """Create a new Tempo customer. KEY: Short, stable customer key (e.g., NEWP) NAME: Full customer display name (e.g., "Example Customer GmbH") Example: tempo-account customer create NEWP "Example Customer GmbH" """ client = ctx.obj["client"] if dry_run: warning("DRY RUN - No customer will be created") print("\nWould create Tempo customer:") print(f" Key: {key}") print(f" Name: {name}") return try: result = client.tempo_account_add_new_customer(key, name) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to create Tempo customer: {e}") sys.exit(1) if ctx.obj["quiet"]: print(key) elif ctx.obj["json"]: format_output(result, as_json=True) else: success(f"Created Tempo customer: {name}") print(f" Key: {key}") @cli.group() def account(): """Tempo account (cost-tracking entity) management.""" @account.command("create") @click.argument("key") @click.argument("name") @click.option("--lead", required=True, help="Username of the account lead") @click.option("--customer-key", required=True, help="Key of an existing Tempo customer this account belongs to") @click.option("--dry-run", is_flag=True, help="Show what would be created without making changes") @click.pass_context def account_create(ctx, key: str, name: str, lead: str, customer_key: str, dry_run: bool): """Create a new Tempo account. KEY: Short, stable account key (e.g., NEWP) NAME: Full account display name (e.g., "Example Customer GmbH") Requires an existing Tempo customer (see: tempo-account customer create). Example: tempo-account account create NEWP "Example Customer GmbH" --lead jane.doe --customer-key NEWP """ client = ctx.obj["client"] data = { "key": key, "name": name, "lead": {"name": lead}, "customer": {"key": customer_key}, } if dry_run: warning("DRY RUN - No account will be created") print("\nWould create Tempo account:") print(f" Key: {key}") print(f" Name: {name}") print(f" Lead: {lead}") print(f" Customer: {customer_key}") return try: result = client.tempo_account_add_account(data) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to create Tempo account: {e}") sys.exit(1) if ctx.obj["quiet"]: account_id = result.get("id") if isinstance(result, dict) else None print(account_id if account_id is not None else key) elif ctx.obj["json"]: format_output(result, as_json=True) else: success(f"Created Tempo account: {name}") print(f" Key: {key}") print(f" Lead: {lead}") print(f" Customer: {customer_key}") if isinstance(result, dict) and result.get("id") is not None: print(f" Account ID: {result['id']}") print(" Note: use this Account ID with 'tempo-account account link' to attach it to a project.") @account.command("link") @click.argument("account_id", type=int) @click.argument("project_key") @click.option("--default", "default_account", is_flag=True, help="Mark this as the project's default account") @click.option("--dry-run", is_flag=True, help="Show what would be linked without making changes") @click.pass_context def account_link(ctx, account_id: int, project_key: str, default_account: bool, dry_run: bool): """Link an existing Tempo account to a Jira project. ACCOUNT_ID: Numeric Tempo account id (printed by 'account create') PROJECT_KEY: Key of the Jira project to link the account to (e.g., NEWP) Example: tempo-account account link 42 NEWP --default """ client = ctx.obj["client"] try: project = client.project(project_key) except Exception as e: error(f"Could not resolve project '{project_key}': {e}") sys.exit(1) project_id = project.get("id") if isinstance(project, dict) else None if not project_id: error(f"Project '{project_key}' has no numeric id in the API response") sys.exit(1) if dry_run: warning("DRY RUN - No link will be created") print("\nWould link Tempo account to project:") print(f" Account ID: {account_id}") print(f" Project: {project_key} (id={project_id})") print(f" Default account: {default_account}") return try: result = client.tempo_account_associate_with_jira_project( account_id, project_id, default_account=default_account ) except Exception as e: if ctx.obj["debug"]: raise error(f"Failed to link Tempo account to project: {e}") sys.exit(1) if ctx.obj["quiet"]: print(project_key) elif ctx.obj["json"]: format_output(result, as_json=True) else: success(f"Linked Tempo account {account_id} to project {project_key}") if default_account: print(" Marked as default account") if __name__ == "__main__": cli()
-
-
-
AGENTS.md 2.1 KB
<!-- 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 ```bash # 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 --verbose ``` ## Code 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.py` for env loading - Test `--dry-run` before actual writes ## PR/commit checklist - [ ] Script follows existing structure (copy from similar script) - [ ] All three output formats work (`--json`, `--quiet`, table) - [ ] `--dry-run` for any write operation - [ ] `--help` is descriptive - [ ] Update SKILL.md with new script docs ## Good vs. bad examples **Adding a new script:** ```python # ✓ Copy structure from existing script # ✓ Use lib/ imports # ✓ Support all output formats # ✗ Write from scratch without looking at patterns # ✗ Hardcode auth or skip --dry-run ``` ## When stuck - Copy structure from `scripts/core/jira-issue.py` (good reference) - Check `lib/` for shared utilities - Run `--help` on 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. -
SKILL.md 7.2 KB
--- name: jira-communication description: "Use when handling Jira issues, sprints, boards, links, fields, worklogs, attachments, or users, or on any Jira intent without a key (\"create/find a ticket\", \"pick a project\"). Auto-triggers on Jira URLs and issue keys (PROJ-123). Also use when MCP Atlassian tools fail or are unavailable for Jira Server/DC." license: "(MIT AND CC-BY-SA-4.0). See LICENSE-MIT and LICENSE-CC-BY-SA-4.0" compatibility: "Requires python 3.10+, uv. Jira Server/DC or Cloud instance with API access." metadata: author: Netresearch DTT GmbH version: "3.32.2" repository: https://github.com/netresearch/jira-skill allowed-tools: Bash(uv run ${CLAUDE_SKILL_DIR}/scripts/*) Bash(${CLAUDE_SKILL_DIR}/scripts/*) Read Write --- # 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` — use the matching verb. ## 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`, `tempo-account.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`. ## Posting wiki markup rewrites and checks it first Every `--comment` and `--description` option that writes wiki markup runs three gates before the write, all on by default, because text that renders wrong is silent — the API returns 2xx either way. That is all seven: `jira-comment.py add`/`edit`, `jira-transition.py do --comment`, `jira-transition.py path --comment`, `jira-worklog.py add --comment`, and the `--description` of `jira-create.py issue` and `jira-issue.py update`. A body smuggled in through `--fields-json` is not gated — that option writes raw fields by design. `jira-version.py` writes two `--description` fields that are NOT gated (`create` and `update`); whether Jira renders a version description as wiki markup at all is unverified, and its help string claiming it does may simply be wrong. 1. **Dashes that Jira would render as strikethrough are escaped.** `\-` prints as a plain hyphen, so the posted text reads as written; stderr names how many lines changed and shows the first five. (The one shape where the escape is visible is two macros written against each other with no space — the dash can land inside a link target. Ordinary prose does not reach it.) `--no-auto-escape` keeps the markup verbatim — but on its own it does not post a deliberate `-strikethrough-`: the lint and the render check each still refuse the span. Use `--no-auto-escape --force` for that. 2. **The markup and the ticket language are linted.** Block tags used inline (`{code}`, `{noformat}`, `{quote}`, `{panel}` are block-level), unbalanced tag counts, and German prose on an English-only project each abort the write. `--force` turns the findings into warnings and posts anyway. 3. **The text is rendered by the instance and refused if it comes back struck through.** This costs one API call per post and catches what no local check can — an autolinked issue key creates a boundary that exists only on an instance where that key resolves. A resolved issue's key, which Jira draws struck through as status styling, is not reported. `--no-preflight` skips it; an unreachable renderer warns once and posts anyway. `--force` posts despite any of the three. The flags are spelled the same on each command. Under `--dry-run` the escape and the lint still run — the preview shows the text a real write would post — while the render call does not. See `references/comments.md` for the details. ## Basic Usage ```bash 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-comment.py add PROJ-123 "Comment text" --no-auto-escape --force # deliberate -strikethrough- 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 ``` > **Transitions**: `list` shows each transition's id and what its screen requires; pass the **id** to `do` — a name or > a target status is not always unique, and an ambiguous one is refused rather than guessed. > **Terminal transitions**: pass `--resolution <value>` (`Done`, `Won't do`); if rejected ("cannot be set"), > retry without it — `references/intent-verbs.md`. **Versions**: read `references/versions.md` before `jira-version.py`. > **Mentions**: posting commands verify `[~username]` (miss → suggestions); `get`/`work` print usernames (`references/fields-and-users.md`). ## Related Skills **jira-syntax**: descriptions/comments use Jira wiki markup, not Markdown. ## No editorializing State what happened, not how good it is — `references/no-editorializing.md`. ## References - `references/jql-quick-reference.md`, `references/jql-cookbook.md` - `references/multi-profile.md` — `--profile` - `references/troubleshooting.md` — auth, 401/403 - `references/issue-editing.md` — edit, delete, clear fields, `--fields-json` - `references/creation.md` — create, `--parent`, fields, admin-scope (`project`, `tempo-account.py`) - `references/comments.md` — edit, delete, lint, body via `-` - `references/worklog.md` — `--started`, ranges, `--tempo-account`, `delete` - `references/attachments.md` — upload, download - `references/links.md` — links - `references/agile.md` — sprints/boards - `references/no-editorializing.md` — no self-praise - `references/fields-and-users.md` — custom field IDs, users, issue types - `references/watchers.md` — watch, subscribe, list watchers - `references/versions.md` — fix/affects versions, releases, version CRUD - `references/qa-gather.md` — 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`.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.