From 2d2c6fe9535367807e66758282bc8976d9107239 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 9 Jun 2026 10:34:20 +0200 Subject: [PATCH 1/6] feat: update skill with new commands --- skills/jira-cli/SKILL.md | 63 +++++++++++++++++------- skills/jira-cli/references/auth-setup.md | 2 + skills/jira-cli/references/workflows.md | 11 +++-- 3 files changed, 54 insertions(+), 22 deletions(-) diff --git a/skills/jira-cli/SKILL.md b/skills/jira-cli/SKILL.md index 438bc5d..5af3f0a 100644 --- a/skills/jira-cli/SKILL.md +++ b/skills/jira-cli/SKILL.md @@ -12,20 +12,33 @@ Non-interactive CLI for Jira Cloud and Server. JSON by default, markdown on requ ## Boot sequence 1. `jira auth status` - is a token present in the keychain? -2. If missing -> walk the user through `references/auth-setup.md`. +2. If missing -> the user logs in themselves; point them to `references/auth-setup.md`. Never handle the token yourself. 3. `jira ping` - verifies token + connectivity against the API. 4. `jira config show` - inspect the active profile and defaults. 5. Pick a command from the index below. +## Draft before writing (human approval required) + +**Never run a mutating command without showing the user the exact command and its effect, then getting their approval.** Covers `issue create`, `edit`, `delete`, `assign`, `move`, `comment add`, `link` / `unlink`, `clone`, and `sprint add` / `start` / `close`. + +Flow: **draft -> approve -> execute.** + +1. **Draft.** Show the exact command line(s) and a one-line summary of the effect. +2. **Approve.** Wait for confirmation. If they want changes, redraft and show it again — never execute a partially-approved change. +3. **Execute.** Run the approved command verbatim. If a key won't resolve or a field is rejected, stop and re-draft — don't improvise a different write. + +Reads (`list`, `view`, `search`, `me`/`mine`, `ping`, board/project/user queries) never mutate — run them freely, including to gather data for an accurate draft. + ## Core rules -1. **JSON is the default output.** Parse it directly. Use `--format markdown` only when rendering for the user. -2. **Never invent keys.** Resolve project keys via `jira project list`, issue keys via `jira issue list` / `jira search`, account IDs via `jira user search`. -3. **JQL values use double quotes:** `status = "In Progress"`, `project = "PROJ"`. -4. **Set context once** to stop repeating flags: `jira context set --project PROJ --board-id 42`. -5. **`--profile NAME`** overrides the active profile for a single command. -6. **`jira auth status` is local-only** (keychain check). Use `jira ping` or `jira auth whoami` to confirm the token actually works. -7. **Custom fields use raw IDs:** `--field customfield_10016=5`. Repeatable. +1. **Draft mutating commands for approval before running them.** See [Draft before writing](#draft-before-writing-human-approval-required). Reads run freely. +2. **JSON is the default output.** Parse it directly. Use `--format markdown` only when rendering for the user. +3. **Never invent keys.** Resolve project keys via `jira project list`, issue keys via `jira issue list` / `jira search`, account IDs via `jira user search`. +4. **JQL values use double quotes:** `status = "In Progress"`, `project = "PROJ"`. +5. **Set context once** to stop repeating flags: `jira context set --project PROJ --board-id 42`. +6. **`--profile NAME`** overrides the active profile for a single command. +7. **`jira auth status` is local-only** (keychain check). Use `jira ping` or `jira auth whoami` to confirm the token actually works. +8. **Custom fields use raw IDs:** `--field customfield_10016=5`. Repeatable. ## Intent -> command @@ -34,26 +47,28 @@ Non-interactive CLI for Jira Cloud and Server. JSON by default, markdown on requ | List issues in current project | `jira issue list` | | List issues assigned to me | `jira mine` (alias `my`); `--all` includes done | | View an issue | `jira issue view PROJ-123` | -| Create an issue | `jira issue create -p PROJ -s "Summary" -t Bug [-d ...] [--priority High]` | -| Edit summary / labels / etc. | `jira issue edit PROJ-123 -s "New summary"` | +| Create an issue | `jira issue create -p PROJ -s "Summary" -t Bug [-b "body"] [-y High]` | +| Edit summary / labels / etc. | `jira issue edit PROJ-123 -s "New summary"` (alias `update`) | | Edit a custom field | `jira issue edit PROJ-123 --field customfield_10016=5` | | Delete an issue | `jira issue delete PROJ-123` | -| Assign to me | `jira issue assign PROJ-123 me` | +| Assign to me | `jira issue assign PROJ-123 me` (`x` to unassign) | | Assign to a user | `jira issue assign PROJ-123 ` | -| Transition status | `jira issue move PROJ-123 "In Progress"` (case-insensitive) | -| Comment | `jira issue comment PROJ-123 -b "text"` | -| Link issues | `jira issue link PROJ-1 PROJ-2 --type Blocks` | +| Transition status | `jira issue move PROJ-123 "In Progress"` (alias `transition`, case-insensitive) | +| Comment | `jira issue comment add PROJ-123 "text"` | +| Link / unlink | `jira issue link PROJ-1 PROJ-2 Blocks`, `jira issue unlink PROJ-1 PROJ-2` | | Clone | `jira issue clone PROJ-123` | | JQL search | `jira search jql "project = PROJ AND ..."` | | Full-text search | `jira search text "terms"` | -| Boards / sprints | `jira board list`, `jira board view 42`, `jira sprint list 42 --state active` | -| Projects | `jira project list`, `jira project view PROJ` | -| Users | `jira user search "jane"` | +| Boards | `jira board list`, `jira board get 42`, `jira board issues 42` | +| Sprints | `jira sprint list 42 --state active`, `jira sprint get `, `jira sprint issues ` | +| Projects | `jira project list`, `jira project get PROJ` | +| Users | `jira user search "jane"`, `jira user get ` | | Current user (display name) | `jira me` (use `--raw` for full JSON) | +| My activity for a day | `jira mine audit [--date YYYY-MM-DD]` (also `jira me audit`) | | Connectivity check | `jira ping` | | Open in browser | `jira open PROJ-123` | | Profiles | `jira config init/list/show/set/use/delete` | -| Context defaults | `jira context set --project PROJ` (also `--board-id`, `--epic`, `--labels`, `--display`) | +| Context defaults | `jira context set --project PROJ` (also `--board-id`, `--epic`, `--label`, `--issue-type`, `--status`, `--assignee`, `--display`) | For anything not listed, run `jira --help` - help is authoritative. @@ -71,3 +86,15 @@ Persistent default: `jira context set --display markdown`. The `--format` flag a - **Auth not working, first-time setup, multiple Jira instances, CI/automation tokens** -> `references/auth-setup.md` - **Writing JQL beyond trivial queries** -> `references/jql-cookbook.md` - **End-to-end issue lifecycle examples** -> `references/workflows.md` + +## Reporting CLI problems + +If the `jira` CLI itself misbehaves (a crash, a wrong result, a missing flag — not a Jira data error), and `gh` is installed (`gh --version` succeeds) and authenticated, offer to file a bug. Get the user's OK first, then: + +```bash +gh issue create --repo AndersSpringborg/jira-agent-cli \ + --title "" \ + --body "" +``` + +If `gh` is missing or not authenticated, give the user the title and body to file manually at https://github.com/AndersSpringborg/jira-agent-cli/issues. Never paste tokens or secrets into an issue. diff --git a/skills/jira-cli/references/auth-setup.md b/skills/jira-cli/references/auth-setup.md index 964e2ed..b599fa8 100644 --- a/skills/jira-cli/references/auth-setup.md +++ b/skills/jira-cli/references/auth-setup.md @@ -1,5 +1,7 @@ # Authentication & profiles +**The user authenticates themselves — the agent never handles the token.** Give the user the commands to run and let them run them (in Claude Code they can run a line inline with `! jira auth login ...`; otherwise they paste it into their own terminal). Never ask the user to paste a token into the conversation, and never put a real token on a command line you execute. + The auth type is auto-detected from the base URL: `*.atlassian.net` -> `basic` (email + API token), anything else -> `pat` (Personal Access Token). Tokens live in the OS keychain - never on disk. ## Jira Cloud (`*.atlassian.net`) diff --git a/skills/jira-cli/references/workflows.md b/skills/jira-cli/references/workflows.md index ef66139..59785b6 100644 --- a/skills/jira-cli/references/workflows.md +++ b/skills/jira-cli/references/workflows.md @@ -1,15 +1,18 @@ # End-to-end workflows +The mutating steps below (create, assign, edit, move, comment, clone, bulk transitions) require user approval first — draft the exact command, get a yes, then run it. + ## Full issue lifecycle ```bash -# 1. Create +# 1. Create (--raw prints the JSON payload incl. .key; default prints a message) jira issue create \ -p PROJ \ -s "Fix login timeout" \ -t Bug \ - -d "Users see timeout after 30s" \ - --priority High + -b "Users see timeout after 30s" \ + -y High \ + --raw # -> { "key": "PROJ-456", ... } # 2. Assign to me @@ -22,7 +25,7 @@ jira issue edit PROJ-456 --field customfield_10016=5 jira issue move PROJ-456 "In Progress" # 5. Comment with findings -jira issue comment PROJ-456 -b "Root cause: connection pool exhaustion" +jira issue comment add PROJ-456 "Root cause: connection pool exhaustion" # 6. Resolve jira issue move PROJ-456 "Done" From b8d3b07309a1ea62e597ad6095d4822d5f28d9a8 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 9 Jun 2026 10:37:23 +0200 Subject: [PATCH 2/6] docs: why agent cli --- README.md | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/README.md b/README.md index fadf9dd..bef2cd4 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,52 @@ This project is inspired by [ankitpokhrel/jira-cli](https://github.com/ankitpokh This project takes a different approach. It is the **kubectl** of Jira: non-interactive, scriptable, and designed for AI agents. No TUI, no prompts -- just structured output that machines can parse. If you want a great interactive experience, use [ankitpokhrel/jira-cli](https://github.com/ankitpokhrel/jira-cli). If you want to wire Jira into an AI agent, CI pipeline, or shell script, use this. +## What makes this an "Agent CLI" + +### 1. Out-of-band Authentication +Authentication and execution are strictly decoupled. +* **Mechanism:** A human or CI process runs `jira auth login` once. The token is stored securely in the **OS keychain, never to disk**. +* **Security Guarantee:** The LLM agent never sees, requests, handles, or routes credential strings. It inherits an already-authenticated environment. Access is revoked system-side, requiring no model trust. + +### 2. Inspectable State & Blast Radius Controls +State is managed via disk, not LLM context windows. +* **Scope:** `jira context set --project PROJ --board-id 42` establishes local default parameters. +* **Blast Radius:** This restricts the agent's default operating scope without requiring the LLM to continuously append `--project` to every call. +* **Overrides:** The context is explicit, inspectable state. If the agent needs to break scope, it must do so explicitly via per-command overrides (e.g., `--profile`). + +### 3. Unix Pipe Compliance (JSON by Default) +The CLI relies on standard shell utilities (`jq`, `rg`, `grep`, `wc`) rather than building a bespoke internal processing engine or requiring SDKs. +* **Strict Stream Separation:** `stdout` emits **only** machine-readable JSON. All diagnostics, warnings, and errors are routed to `stderr`. This guarantees pipeline tools like `jq` will not choke on error blobs. +* **Reliable Exit Codes:** Failed API calls or validations result in a non-zero exit code. Agents detect success/failure via the `$?` status code natively, avoiding the need to parse output strings to determine operation state. + +```bash +# Extract issue keys natively +jira search jql "assignee = currentUser()" | jq -r '.[].key' + +# Filter and aggregate +jira issue list | jq '[.[] | select(.status.name=="Done")] | length' + +# Inspect schemas +jira issue view CER-1 --raw | rg -o '"customfield_\d+"' +``` +*(Note: `--format markdown` is supported solely for human-readable summaries, but is not the default path).* + +### 4. Idempotent Discovery & Field Projection +Read paths are explicitly non-mutating and structured for fanning out searches. +* **Stable Schemas:** Operations like `search jql`, `search text`, and `issue view` return consistent, keyed JSON objects (`key`, `fields.*`). +* **Targeted Retrieval:** Agents can project exact custom fields into any read path via repeatable flags (`--field customfield_x` or `-F`). This ensures the agent retrieves only the data necessary for its reasoning loop, minimizing token overhead. + +### 5. CLI over MCP (Stateless vs. Stateful) +Instead of implementing a Model Context Protocol (MCP) server, this tool utilizes a standard CLI binary. +* **Zero Standing Surface:** There is no long-lived daemon, no open socket, and no persistent state between executions. +* **Direct Replayability:** The interface is the exact execution string. To debug an agent, a human simply copies the exact command from the shell history and executes it. +* **Native Composability:** Agents already understand shell operators (`&&`, `||`, `|`, `>`). A CLI leverages existing agent capabilities instead of requiring the LLM to learn a custom RPC protocol. + +### 6. Dual-Layer Auditing +Verification relies on hard records, not LLM transcripts. +* **Local Audit (Execution):** The shell history acts as the immutable log of *what was attempted*. The inputs are explicit command-line arguments. +* **Remote Audit (Mutation):** `jira me audit` (or `jira mine audit --date YYYY-MM-DD`) queries Jira's server-side changelogs. It reconstructs exactly *what mutated* on a given day based on the authenticated user's activity. This provides an independent, cryptographic-equivalent verification of the agent's actual impact, regardless of local terminal state. + ## Install ### npm (recommended) @@ -132,6 +178,43 @@ jira context set --display markdown The `--format` flag always takes precedence over the context default. +## Writing to Jira + +Reads are only half the job -- an agent has to *act*. Every mutation is a single command with explicit flags, so the line in the transcript is exactly what changed (see [Why a CLI instead of an MCP server](#5-why-a-cli-instead-of-an-mcp-server)). Like the read paths, write commands print the result as JSON and exit non-zero on failure, so an agent can chain them with `&&` and check success from the exit code. + +```bash +# Create an issue (returns the new key on stdout) +jira issue create -p PROJ -s "Fix login bug" -t Bug -b "Steps to reproduce..." + +# Edit fields, including custom fields by id (-F is repeatable) +jira issue edit PROJ-123 -s "New summary" -l backend -F customfield_10145="value" + +# Transition through the workflow +jira issue move PROJ-123 "In Progress" +jira issue move PROJ-123 Done --resolution Fixed --comment "Shipped in v1.2" + +# Assign and comment ('me' for yourself, 'x' to unassign) +jira issue assign PROJ-123 alice@example.com +jira issue comment add PROJ-123 "Investigated -- root cause was a stale cache." + +# Link, clone, delete +jira issue link PROJ-123 PROJ-456 "blocks" +jira issue clone PROJ-123 -s "Follow-up: ..." +jira issue delete PROJ-123 + +# Sprint management +jira sprint add 42 PROJ-123 PROJ-456 +``` + +Capture a created key and act on it in the same script: + +```bash +key=$(jira issue create -p PROJ -s "Automated task" -t Task --raw | jq -r '.key') +jira issue move "$key" "In Progress" && jira issue assign "$key" me +``` + +Run `jira issue --help` for the full flag set on any write command. + ## Commands | Command | Description | From 11adb919df9ff6a94616005942bd19dc134b1eb3 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 9 Jun 2026 10:42:23 +0200 Subject: [PATCH 3/6] docs: why agent cli --- README.md | 241 +++++++++++++++++++++--------------------------------- 1 file changed, 95 insertions(+), 146 deletions(-) diff --git a/README.md b/README.md index bef2cd4..ec07dd0 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,28 @@ # jira-cli -A non-interactive CLI for Jira designed for AI agents and automation. All output is machine-readable JSON by default, making it ideal for `jq` pipelines and LLM tool calling. +A non-interactive CLI for Jira built for AI agents, CI, and automation. All output is machine-readable JSON by default, so it drops straight into `jq` pipelines, shell scripts, and LLM tool calling. -## Credit - -This project is inspired by [ankitpokhrel/jira-cli](https://github.com/ankitpokhrel/jira-cli) -- a feature-rich **interactive** Jira command line with a full TUI (tables, keyboard navigation, interactive prompts). Think of it as the [k9s](https://k9scli.io/) of Jira: powerful, visual, and built for humans at a terminal. - -This project takes a different approach. It is the **kubectl** of Jira: non-interactive, scriptable, and designed for AI agents. No TUI, no prompts -- just structured output that machines can parse. If you want a great interactive experience, use [ankitpokhrel/jira-cli](https://github.com/ankitpokhrel/jira-cli). If you want to wire Jira into an AI agent, CI pipeline, or shell script, use this. - -## What makes this an "Agent CLI" - -### 1. Out-of-band Authentication -Authentication and execution are strictly decoupled. -* **Mechanism:** A human or CI process runs `jira auth login` once. The token is stored securely in the **OS keychain, never to disk**. -* **Security Guarantee:** The LLM agent never sees, requests, handles, or routes credential strings. It inherits an already-authenticated environment. Access is revoked system-side, requiring no model trust. - -### 2. Inspectable State & Blast Radius Controls -State is managed via disk, not LLM context windows. -* **Scope:** `jira context set --project PROJ --board-id 42` establishes local default parameters. -* **Blast Radius:** This restricts the agent's default operating scope without requiring the LLM to continuously append `--project` to every call. -* **Overrides:** The context is explicit, inspectable state. If the agent needs to break scope, it must do so explicitly via per-command overrides (e.g., `--profile`). +```bash +jira search jql "assignee = currentUser() AND status != Done" | jq -r '.[].key' +``` -### 3. Unix Pipe Compliance (JSON by Default) -The CLI relies on standard shell utilities (`jq`, `rg`, `grep`, `wc`) rather than building a bespoke internal processing engine or requiring SDKs. -* **Strict Stream Separation:** `stdout` emits **only** machine-readable JSON. All diagnostics, warnings, and errors are routed to `stderr`. This guarantees pipeline tools like `jq` will not choke on error blobs. -* **Reliable Exit Codes:** Failed API calls or validations result in a non-zero exit code. Agents detect success/failure via the `$?` status code natively, avoiding the need to parse output strings to determine operation state. +## Why use this -```bash -# Extract issue keys natively -jira search jql "assignee = currentUser()" | jq -r '.[].key' +- **JSON by default, clean streams.** Results go to `stdout`, all diagnostics to `stderr`, and failures return a non-zero exit code. `jq`, `rg`, and `wc` work without choking on error blobs, and scripts check `$?` instead of parsing strings. +- **Auth stays out of band.** You log in once; the token lives in the OS keychain and is never written to disk or exposed to an agent. Execution inherits an already-authenticated environment. +- **Inspectable, scoped state.** `jira context set --project PROJ` pins defaults on disk so you don't repeat `--project` on every call — and you can read back exactly what scope you're operating in. +- **Lean output.** Project only the fields you need with repeatable `-F customfield_x` flags, keeping responses (and token usage) small. +- **No daemon, no custom protocol.** It's a plain binary. Every action is one replayable command line — debug by copying it from your shell history and running it again. +- **Server-side audit.** `jira me audit --date YYYY-MM-DD` reconstructs what actually changed on a given day from Jira's own changelog, independent of local state. -# Filter and aggregate -jira issue list | jq '[.[] | select(.status.name=="Done")] | length' +See [Design notes](#design-notes) for the longer rationale. -# Inspect schemas -jira issue view CER-1 --raw | rg -o '"customfield_\d+"' -``` -*(Note: `--format markdown` is supported solely for human-readable summaries, but is not the default path).* +## Why not the existing jira-cli? -### 4. Idempotent Discovery & Field Projection -Read paths are explicitly non-mutating and structured for fanning out searches. -* **Stable Schemas:** Operations like `search jql`, `search text`, and `issue view` return consistent, keyed JSON objects (`key`, `fields.*`). -* **Targeted Retrieval:** Agents can project exact custom fields into any read path via repeatable flags (`--field customfield_x` or `-F`). This ensures the agent retrieves only the data necessary for its reasoning loop, minimizing token overhead. +This project is00 inspired by [ankitpokhrel/jira-cli](https://github.com/ankitpokhrel/jira-cli) — a feature-rich **interactive** Jira command line with a full TUI (tables, keyboard navigation, prompts). Think of it as the [k9s](https://k9scli.io/) of Jira: visual and built for humans at a terminal. -### 5. CLI over MCP (Stateless vs. Stateful) -Instead of implementing a Model Context Protocol (MCP) server, this tool utilizes a standard CLI binary. -* **Zero Standing Surface:** There is no long-lived daemon, no open socket, and no persistent state between executions. -* **Direct Replayability:** The interface is the exact execution string. To debug an agent, a human simply copies the exact command from the shell history and executes it. -* **Native Composability:** Agents already understand shell operators (`&&`, `||`, `|`, `>`). A CLI leverages existing agent capabilities instead of requiring the LLM to learn a custom RPC protocol. +This takes the opposite approach — the **kubectl** of Jira: non-interactive, scriptable, and built for agents and automation. No TUI, no prompts, just structured output machines can parse. Want a great interactive experience? Use that one. Want to wire Jira into an AI agent, CI pipeline, or shell script? Use this. -### 6. Dual-Layer Auditing -Verification relies on hard records, not LLM transcripts. -* **Local Audit (Execution):** The shell history acts as the immutable log of *what was attempted*. The inputs are explicit command-line arguments. -* **Remote Audit (Mutation):** `jira me audit` (or `jira mine audit --date YYYY-MM-DD`) queries Jira's server-side changelogs. It reconstructs exactly *what mutated* on a given day based on the authenticated user's activity. This provides an independent, cryptographic-equivalent verification of the agent's actual impact, regardless of local terminal state. ## Install @@ -62,7 +32,7 @@ Verification relies on hard records, not LLM transcripts. npm install -g @888aaen/jira-cli ``` -This installs the `jira` binary for your platform. Works on macOS (arm64, x64), Linux (x64, arm64), and Windows (x64). +Installs the `jira` binary for macOS (arm64, x64), Linux (x64, arm64), and Windows (x64). ### Build from source @@ -71,119 +41,76 @@ This installs the `jira` binary for your platform. Works on macOS (arm64, x64), ```bash git clone https://github.com/AndersSpringborg/jira-agent-cli.git cd jira-agent-cli -sudo make install +sudo make install # builds and installs to /usr/local/bin/jira +sudo make uninstall # to remove ``` -This builds the binary and copies it to `/usr/local/bin/jira`. +## Quick Start (AI agent) -To uninstall: - -```bash -sudo make uninstall -``` - -## Quick Start (AI Agent) - -Give your AI agent Jira superpowers in two commands: +Give an agent Jira access in two commands: ```bash npm install -g @888aaen/jira-cli npx skills@latest add AndersSpringborg/jira-agent-cli ``` -The first installs the `jira` binary. The second installs the [jira-cli skill](skills/jira-cli/SKILL.md) into `~/.claude/skills/jira-cli/` so any Claude Code agent on the machine learns how to drive it. +The first installs the `jira` binary. The second installs the [jira-cli skill](skills/jira-cli/SKILL.md) into `~/.claude/skills/jira-cli/`, so any Claude Code agent on the machine learns to drive it — it checks for an existing session, guides you through login if needed, and picks the right command for each request. -After adding the skill, the agent will: -1. Check for an existing Jira auth session -2. Guide you through login if needed -3. Use the right `jira` command for any Jira-related request - -## Quick Start +## Quick Start (manual) ### 1. Authenticate -**Jira Cloud** (*.atlassian.net): - -1. Create an API token at https://id.atlassian.com/manage-profile/security/api-tokens -2. Run: - -```bash -jira auth login \ - --server https://your-org.atlassian.net \ - --email you@example.com \ - --token YOUR_API_TOKEN -``` - -**Jira Server / Data Center** (Personal Access Token): - -1. In Jira, go to Profile > Personal Access Tokens -2. Run: +**Jira Cloud** (`*.atlassian.net`) — create an API token at https://id.atlassian.com/manage-profile/security/api-tokens, then: ```bash -jira auth login \ - --server https://jira.example.com \ - --token YOUR_PAT +jira auth login --server https://your-org.atlassian.net \ + --email you@example.com --token YOUR_API_TOKEN ``` -Your token is stored in the OS keychain -- never written to disk. - -### 2. Verify connectivity +**Jira Server / Data Center** — use a Personal Access Token (Profile > Personal Access Tokens): ```bash -jira ping +jira auth login --server https://jira.example.com --token YOUR_PAT ``` -### 3. Set a default project (optional) +The token is stored in the OS keychain — never written to disk. -The context system lets you set defaults so you don't have to repeat flags: +### 2. Verify and set defaults ```bash -jira context set --project PROJ +jira ping # check connectivity +jira context set --project PROJ # default project for subsequent commands jira context set --board-id 42 +jira context set --display markdown # human-readable output everywhere (json is the default) ``` -Now commands like `jira issue list` automatically filter to project `PROJ`. +A per-command `--format` flag always overrides the context default. See [Output Formats](#output-formats). -### 4. Start using it +### 3. Use it ```bash -# List issues in your project -jira issue list - -# View a specific issue +jira issue list # issues in your project jira issue view PROJ-123 - -# Create an issue jira issue create -p PROJ -s "Fix login bug" -t Bug - -# Search with JQL jira search jql "project = PROJ AND status = 'In Progress'" - -# Pipe to jq jira issue list | jq '.[].key' ``` ## Output Formats -| Flag | Description | -|----------------------|------------------------------------------| -| `--format json` | Machine-readable JSON (default) | -| `--format markdown` | Structured markdown optimized for LLMs | +| Flag | Description | +|---------------------|------------------------------------------| +| `--format json` | Machine-readable JSON (default) | +| `--format markdown` | Structured markdown, fewer tokens for LLMs | -Set a persistent default with: - -```bash -jira context set --display markdown -``` - -The `--format` flag always takes precedence over the context default. +Set a persistent default with `jira context set --display markdown`; `--format` always overrides it. ## Writing to Jira -Reads are only half the job -- an agent has to *act*. Every mutation is a single command with explicit flags, so the line in the transcript is exactly what changed (see [Why a CLI instead of an MCP server](#5-why-a-cli-instead-of-an-mcp-server)). Like the read paths, write commands print the result as JSON and exit non-zero on failure, so an agent can chain them with `&&` and check success from the exit code. +Every mutation is a single command with explicit flags, so the transcript line is exactly what changed. Write commands print the result as JSON and exit non-zero on failure, so you can chain them with `&&` and check the exit code. ```bash -# Create an issue (returns the new key on stdout) +# Create (returns the new key on stdout) jira issue create -p PROJ -s "Fix login bug" -t Bug -b "Steps to reproduce..." # Edit fields, including custom fields by id (-F is repeatable) @@ -213,56 +140,78 @@ key=$(jira issue create -p PROJ -s "Automated task" -t Task --raw | jq -r '.key' jira issue move "$key" "In Progress" && jira issue assign "$key" me ``` -Run `jira issue --help` for the full flag set on any write command. +Run `jira issue --help` for the full flag set on any command. ## Commands -| Command | Description | -|-----------------|----------------------------------------------| -| `jira auth` | Login, logout, status, whoami | -| `jira config` | Manage profiles (init, list, show, set, use, delete) | -| `jira context` | Set default filters (project, board, labels, etc.) | -| `jira issue` | Full issue lifecycle (list, view, create, edit, delete, assign, move, comment, link, clone) | -| `jira board` | List boards, view board issues | -| `jira sprint` | List, start, close sprints; add issues | -| `jira project` | List and view projects | -| `jira search` | JQL and full-text search | -| `jira user` | Search and get users | -| `jira me` | Show current user | -| `jira open` | Open project or issue in browser | -| `jira ping` | Check connectivity to Jira | +| Command | Description | +|----------------|----------------------------------------------| +| `jira auth` | Login, logout, status, whoami | +| `jira config` | Manage profiles (init, list, show, set, use, delete) | +| `jira context` | Set default filters (project, board, labels, etc.) | +| `jira issue` | Full issue lifecycle (list, view, create, edit, delete, assign, move, comment, link, clone) | +| `jira board` | List boards, view board issues | +| `jira sprint` | List, start, close sprints; add issues | +| `jira project` | List and view projects | +| `jira search` | JQL and full-text search | +| `jira user` | Search and get users | +| `jira me` | Show current user; `me audit` for daily activity | +| `jira mine` | List issues assigned to you | +| `jira open` | Open project or issue in browser | +| `jira ping` | Check connectivity to Jira | Run `jira --help` for details on any command. ## Configuration -Config lives at `~/.config/jira-cli/config.yml`. You normally don't need to edit it by hand -- use the `jira config` and `jira context` commands instead. +Config lives at `~/.config/jira-cli/config.yml`. You normally don't edit it by hand — use the `jira config` and `jira context` commands. ### Profiles -Profiles let you manage multiple Jira instances: +Manage multiple Jira instances: ```bash -# Create a profile for a second instance jira config init --profile work --base-url https://work.atlassian.net jira auth login --profile work --server https://work.atlassian.net \ --email you@work.com --token YOUR_TOKEN - -# Switch default profile -jira config use work - -# Use a profile for a single command -jira issue list --profile work +jira config use work # switch default +jira issue list --profile work # use a profile for one command ``` ### Environment Variables These override config file values and are useful in CI/automation: -| Variable | Description | -|-------------------|-------------------------------------| -| `JIRA_BASE_URL` | Jira server URL | -| `JIRA_TOKEN` | API token (bypasses OS keychain) | -| `JIRA_EMAIL` | User email | -| `JIRA_AUTH_TYPE` | Auth type: `basic` or `pat` | -| `JIRABOT_PROFILE` | Profile name to use | +| Variable | Description | +|-------------------|-----------------------------------| +| `JIRA_BASE_URL` | Jira server URL | +| `JIRA_TOKEN` | API token (bypasses OS keychain) | +| `JIRA_EMAIL` | User email | +| `JIRA_AUTH_TYPE` | Auth type: `basic` or `pat` | +| `JIRABOT_PROFILE` | Profile name to use | + +## What makes this a agent CLI + +### Out-of-band authentication +Authentication and execution are decoupled. A human or CI process runs `jira auth login` once; the token is stored in the OS keychain, never on disk. An agent never sees, requests, or routes the credential — it inherits an already-authenticated environment, and access is revoked system-side without any model trust. + +### Inspectable state and blast-radius control +`jira context set --project PROJ --board-id 42` writes default parameters to disk, restricting the default operating scope without an agent having to append `--project` to every call. The context is explicit, inspectable state; breaking scope requires an explicit per-command override (e.g. `--profile`). + +### Built for the Unix pipe +The CLI leans on standard tools (`jq`, `rg`, `grep`, `wc`) instead of a bespoke processing engine. `stdout` carries only the result; diagnostics, warnings, and errors go to `stderr`, so pipelines never choke on error output. Failed calls return a non-zero exit code, so success/failure is read from `$?` rather than parsed from text. + +```bash +jira search jql "assignee = currentUser()" | jq -r '.[].key' +jira issue list | jq '[.[] | select(.status.name=="Done")] | length' +jira issue view CER-1 --raw | rg -o '"customfield_\d+"' +``` + +### Non-mutating discovery and field projection +Read paths (`search jql`, `search text`, `issue view`) are non-mutating and return consistent, keyed JSON (`key`, `fields.*`). Repeatable `--field`/`-F` flags project exact custom fields into any read path, so an agent retrieves only what it needs and minimizes token overhead. + +### CLI over MCP +Rather than running a Model Context Protocol server, this is a standard binary: no long-lived daemon, no open socket, no persistent state between executions. Every action is the exact execution string, so debugging is just re-running the command from shell history, and agents reuse shell operators (`&&`, `||`, `|`, `>`) instead of learning a custom RPC protocol. + +### Dual-layer auditing +Verification rests on hard records, not transcripts. Shell history is a replayable record of *what was attempted* — every input is a command-line argument, not hidden state. `jira me audit --date YYYY-MM-DD` queries Jira's server-side changelog to reconstruct *what actually mutated* on a given day for the authenticated user, independent of local terminal state. From 5619cab01b7ddda2e24c682a636ab6d7c503b499 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 9 Jun 2026 11:21:29 +0200 Subject: [PATCH 4/6] docs: cloud jira --- README.md | 26 +++++++++++------------- skills/jira-cli/SKILL.md | 2 +- skills/jira-cli/references/auth-setup.md | 8 +++++++- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index ec07dd0..6da08b6 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ jira search jql "assignee = currentUser() AND status != Done" | jq -r '.[].key' - **No daemon, no custom protocol.** It's a plain binary. Every action is one replayable command line — debug by copying it from your shell history and running it again. - **Server-side audit.** `jira me audit --date YYYY-MM-DD` reconstructs what actually changed on a given day from Jira's own changelog, independent of local state. -See [Design notes](#design-notes) for the longer rationale. +See [What makes this an agent CLI](#what-makes-this-an-agent-cli) for the longer rationale. ## Why not the existing jira-cli? -This project is00 inspired by [ankitpokhrel/jira-cli](https://github.com/ankitpokhrel/jira-cli) — a feature-rich **interactive** Jira command line with a full TUI (tables, keyboard navigation, prompts). Think of it as the [k9s](https://k9scli.io/) of Jira: visual and built for humans at a terminal. +This project is inspired by [ankitpokhrel/jira-cli](https://github.com/ankitpokhrel/jira-cli) — a feature-rich **interactive** Jira command line with a full TUI (tables, keyboard navigation, prompts). Think of it as the [k9s](https://k9scli.io/) of Jira: visual and built for humans at a terminal. This takes the opposite approach — the **kubectl** of Jira: non-interactive, scriptable, and built for agents and automation. No TUI, no prompts, just structured output machines can parse. Want a great interactive experience? Use that one. Want to wire Jira into an AI agent, CI pipeline, or shell script? Use this. @@ -60,21 +60,18 @@ The first installs the `jira` binary. The second installs the [jira-cli skill](s ### 1. Authenticate -**Jira Cloud** (`*.atlassian.net`) — create an API token at https://id.atlassian.com/manage-profile/security/api-tokens, then: +Create an API token at https://id.atlassian.com/manage-profile/security/api-tokens, then: ```bash jira auth login --server https://your-org.atlassian.net \ --email you@example.com --token YOUR_API_TOKEN ``` -**Jira Server / Data Center** — use a Personal Access Token (Profile > Personal Access Tokens): - -```bash -jira auth login --server https://jira.example.com --token YOUR_PAT -``` - The token is stored in the OS keychain — never written to disk. +> **Jira Cloud only (for now).** All API calls target the Cloud REST v3 API, so +> Jira Server / Data Center instances are not yet supported. Support is planned. + ### 2. Verify and set defaults ```bash @@ -110,7 +107,7 @@ Set a persistent default with `jira context set --display markdown`; `--format` Every mutation is a single command with explicit flags, so the transcript line is exactly what changed. Write commands print the result as JSON and exit non-zero on failure, so you can chain them with `&&` and check the exit code. ```bash -# Create (returns the new key on stdout) +# Create (add --raw to get the new issue as JSON, e.g. to read .key) jira issue create -p PROJ -s "Fix login bug" -t Bug -b "Steps to reproduce..." # Edit fields, including custom fields by id (-F is repeatable) @@ -120,8 +117,9 @@ jira issue edit PROJ-123 -s "New summary" -l backend -F customfield_10145="value jira issue move PROJ-123 "In Progress" jira issue move PROJ-123 Done --resolution Fixed --comment "Shipped in v1.2" -# Assign and comment ('me' for yourself, 'x' to unassign) -jira issue assign PROJ-123 alice@example.com +# Assign and comment ('me' for yourself, 'x' to unassign; otherwise an account ID +# from `jira user search`) +jira issue assign PROJ-123 me jira issue comment add PROJ-123 "Investigated -- root cause was a stale cache." # Link, clone, delete @@ -190,7 +188,7 @@ These override config file values and are useful in CI/automation: | `JIRA_AUTH_TYPE` | Auth type: `basic` or `pat` | | `JIRABOT_PROFILE` | Profile name to use | -## What makes this a agent CLI +## What makes this an agent CLI ### Out-of-band authentication Authentication and execution are decoupled. A human or CI process runs `jira auth login` once; the token is stored in the OS keychain, never on disk. An agent never sees, requests, or routes the credential — it inherits an already-authenticated environment, and access is revoked system-side without any model trust. @@ -208,7 +206,7 @@ jira issue view CER-1 --raw | rg -o '"customfield_\d+"' ``` ### Non-mutating discovery and field projection -Read paths (`search jql`, `search text`, `issue view`) are non-mutating and return consistent, keyed JSON (`key`, `fields.*`). Repeatable `--field`/`-F` flags project exact custom fields into any read path, so an agent retrieves only what it needs and minimizes token overhead. +Read paths (`search jql`, `search text`, `issue list`, `mine`, `issue view`) are non-mutating and return consistent, keyed JSON (`key`, `fields.*`). Custom-field projection keeps responses small so an agent retrieves only what it needs: `search`, `list`, and `mine` take repeatable `--field`/`-F` flags; `issue view` takes a comma-separated `--fields`. ### CLI over MCP Rather than running a Model Context Protocol server, this is a standard binary: no long-lived daemon, no open socket, no persistent state between executions. Every action is the exact execution string, so debugging is just re-running the command from shell history, and agents reuse shell operators (`&&`, `||`, `|`, `>`) instead of learning a custom RPC protocol. diff --git a/skills/jira-cli/SKILL.md b/skills/jira-cli/SKILL.md index 5af3f0a..3ad5a28 100644 --- a/skills/jira-cli/SKILL.md +++ b/skills/jira-cli/SKILL.md @@ -5,7 +5,7 @@ description: Drive Jira from the shell via the `jira` CLI - read, create, edit, # jira-cli -Non-interactive CLI for Jira Cloud and Server. JSON by default, markdown on request, designed to be driven by an agent. +Non-interactive CLI for Jira Cloud. JSON by default, markdown on request, designed to be driven by an agent. (Jira Server / Data Center is not yet supported — all calls use the Cloud REST v3 API.) **Prerequisite:** the `jira` binary must be installed (`npm install -g @888aaen/jira-cli`) and authenticated. If `jira ping` fails, see `references/auth-setup.md`. diff --git a/skills/jira-cli/references/auth-setup.md b/skills/jira-cli/references/auth-setup.md index b599fa8..5e9e493 100644 --- a/skills/jira-cli/references/auth-setup.md +++ b/skills/jira-cli/references/auth-setup.md @@ -4,6 +4,8 @@ The auth type is auto-detected from the base URL: `*.atlassian.net` -> `basic` (email + API token), anything else -> `pat` (Personal Access Token). Tokens live in the OS keychain - never on disk. +> **Jira Cloud only (for now).** All API calls target the Cloud REST v3 API, so Jira Server / Data Center is not yet functional even though PAT login is accepted. Support is planned. Use a Cloud (`*.atlassian.net`) instance. + ## Jira Cloud (`*.atlassian.net`) 1. User creates an API token at https://id.atlassian.com/manage-profile/security/api-tokens. @@ -17,7 +19,11 @@ jira auth login \ --token API_TOKEN ``` -## Jira Server / Data Center (PAT) +## Jira Server / Data Center (PAT) — not yet supported + +PAT login is accepted, but API calls currently target the Cloud v3 endpoints, so +requests against a Server / Data Center instance will fail. Documented here for when +support lands. 1. User creates a Personal Access Token under Profile -> Personal Access Tokens. 2. Initialize and log in: From 5e6cde4ee02ad193698292d0ee1555ae01eb5a05 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 9 Jun 2026 12:33:47 +0200 Subject: [PATCH 5/6] feat: data center strategy --- internal/jira/client.go | 80 ++++++++++++++---------------------- internal/jira/client_test.go | 42 +++++++++++++++++++ internal/jira/strategy.go | 73 ++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 49 deletions(-) create mode 100644 internal/jira/strategy.go diff --git a/internal/jira/client.go b/internal/jira/client.go index f1fb489..e3034af 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -29,6 +29,7 @@ type Client struct { email string token string authType string // "basic" or "pat"/"bearer" + strategy Strategy } // NewClient creates a new Jira client. @@ -54,6 +55,7 @@ func NewClient(baseURL, email, token, authType string, timeout float64) (*Client email: email, token: token, authType: authType, + strategy: selectStrategy(baseURL, authType), httpClient: &http.Client{ Transport: transport, Timeout: time.Duration(timeout) * time.Second, @@ -65,7 +67,7 @@ func NewClient(baseURL, email, token, authType string, timeout float64) (*Client // GetMyself returns the currently authenticated user. func (c *Client) GetMyself() (map[string]any, error) { - return c.getJSON("/rest/api/3/myself") + return c.getJSON(c.strategy.APIPath("myself")) } // --- Issues --- @@ -73,7 +75,7 @@ func (c *Client) GetMyself() (map[string]any, error) { // GetIssue fetches a single issue by key. // fields optionally limits which fields are returned. func (c *Client) GetIssue(key string, fields []string) (map[string]any, error) { - path := fmt.Sprintf("/rest/api/3/issue/%s", key) + path := c.strategy.APIPath(fmt.Sprintf("issue/%s", key)) if len(fields) > 0 { path += "?fields=" + strings.Join(fields, ",") } @@ -88,7 +90,7 @@ func (c *Client) CreateIssue(project, summary, issueType, description, priority "issuetype": map[string]any{"name": issueType}, } if description != "" { - fields["description"] = textToADF(description) + fields["description"] = c.strategy.TextBody(description) } if priority != "" { fields["priority"] = map[string]any{"name": priority} @@ -117,12 +119,15 @@ func (c *Client) CreateIssue(project, summary, issueType, description, priority body := map[string]any{ "fields": fields, } - return c.postJSON("/rest/api/3/issue", body) + return c.postJSON(c.strategy.APIPath("issue"), body) } // UpdateIssue updates an existing issue's fields. func (c *Client) UpdateIssue(key string, fields map[string]any) error { - path := fmt.Sprintf("/rest/api/3/issue/%s", key) + path := c.strategy.APIPath(fmt.Sprintf("issue/%s", key)) + if desc, ok := fields["description"].(string); ok && desc != "" { + fields["description"] = c.strategy.TextBody(desc) + } body := map[string]any{"fields": fields} return c.putNoContent(path, body) } @@ -132,9 +137,9 @@ func (c *Client) UpdateIssue(key string, fields map[string]any) error { // description, priority, custom fields) and "update" for array operations // (add/remove on labels, components, fixVersions). func (c *Client) EditIssue(key string, fields, update map[string]any) error { - path := fmt.Sprintf("/rest/api/3/issue/%s", key) + path := c.strategy.APIPath(fmt.Sprintf("issue/%s", key)) if desc, ok := fields["description"].(string); ok && desc != "" { - fields["description"] = textToADF(desc) + fields["description"] = c.strategy.TextBody(desc) } body := map[string]any{} if len(fields) > 0 { @@ -148,7 +153,7 @@ func (c *Client) EditIssue(key string, fields, update map[string]any) error { // DeleteIssue deletes an issue by key. func (c *Client) DeleteIssue(key string) error { - path := fmt.Sprintf("/rest/api/3/issue/%s", key) + path := c.strategy.APIPath(fmt.Sprintf("issue/%s", key)) return c.delete(path) } @@ -187,28 +192,19 @@ func (c *Client) CloneIssue(key string, overrides map[string]any) (map[string]an } body := map[string]any{"fields": newFields} - return c.postJSON("/rest/api/3/issue", body) + return c.postJSON(c.strategy.APIPath("issue"), body) } // AssignIssue assigns an issue to a user. // Pass accountID for cloud, name for server. Pass empty strings for unassigned. func (c *Client) AssignIssue(key, accountID, name, _ string) error { - path := fmt.Sprintf("/rest/api/3/issue/%s/assignee", key) - body := map[string]any{} - switch { - case accountID != "": - body["accountId"] = accountID - case name != "": - body["name"] = name - default: - body["accountId"] = nil - } - return c.putNoContent(path, body) + path := c.strategy.APIPath(fmt.Sprintf("issue/%s/assignee", key)) + return c.putNoContent(path, c.strategy.AssignBody(accountID, name)) } // GetIssueTransitions returns available transitions for an issue. func (c *Client) GetIssueTransitions(key string) ([]map[string]any, error) { - path := fmt.Sprintf("/rest/api/3/issue/%s/transitions", key) + path := c.strategy.APIPath(fmt.Sprintf("issue/%s/transitions", key)) data, err := c.getJSON(path) if err != nil { return nil, err @@ -218,7 +214,7 @@ func (c *Client) GetIssueTransitions(key string) ([]map[string]any, error) { // TransitionIssue moves an issue to a new status via transition ID. func (c *Client) TransitionIssue(key, transitionID string) error { - path := fmt.Sprintf("/rest/api/3/issue/%s/transitions", key) + path := c.strategy.APIPath(fmt.Sprintf("issue/%s/transitions", key)) body := map[string]any{ "transition": map[string]any{"id": transitionID}, } @@ -228,23 +224,9 @@ func (c *Client) TransitionIssue(key, transitionID string) error { // AddComment adds a comment to an issue. func (c *Client) AddComment(key, body string) error { - path := fmt.Sprintf("/rest/api/3/issue/%s/comment", key) + path := c.strategy.APIPath(fmt.Sprintf("issue/%s/comment", key)) payload := map[string]any{ - "body": map[string]any{ - "type": "doc", - "version": 1, - "content": []any{ - map[string]any{ - "type": "paragraph", - "content": []any{ - map[string]any{ - "type": "text", - "text": body, - }, - }, - }, - }, - }, + "body": c.strategy.CommentBody(body), } _, err := c.postJSON(path, payload) return err @@ -257,7 +239,7 @@ func (c *Client) LinkIssues(inward, outward, linkType string) error { "outwardIssue": map[string]any{"key": outward}, "type": map[string]any{"name": linkType}, } - _, err := c.postJSON("/rest/api/3/issueLink", body) + _, err := c.postJSON(c.strategy.APIPath("issueLink"), body) return err } @@ -276,21 +258,21 @@ func (c *Client) GetIssueLinks(key string) ([]map[string]any, error) { // DeleteIssueLink deletes an issue link by ID. func (c *Client) DeleteIssueLink(linkID string) error { - path := fmt.Sprintf("/rest/api/3/issueLink/%s", linkID) + path := c.strategy.APIPath(fmt.Sprintf("issueLink/%s", linkID)) return c.delete(path) } // --- Search --- -// Search executes a JQL search query using the /rest/api/3/search/jql endpoint. +// Search executes a JQL search query using the selected Jira REST API strategy. // Requests key and common fields by default so results are useful. func (c *Client) Search(jql string, startAt, maxResults int, extraFields ...string) (map[string]any, error) { fields := "key,summary,status,assignee,priority,issuetype,reporter,resolution,created,updated,labels,description,comment" if len(extraFields) > 0 { fields += "," + strings.Join(extraFields, ",") } - path := fmt.Sprintf("/rest/api/3/search/jql?jql=%s&startAt=%d&maxResults=%d&fields=%s", - urlEncode(jql), startAt, maxResults, fields) + path := fmt.Sprintf("%s?jql=%s&startAt=%d&maxResults=%d&fields=%s", + c.strategy.SearchPath(), urlEncode(jql), startAt, maxResults, fields) return c.getJSON(path) } @@ -298,8 +280,8 @@ func (c *Client) Search(jql string, startAt, maxResults int, extraFields ...stri // changelog, so callers can inspect change history. Used by `me audit` to // reconstruct a user's activity from issue history. func (c *Client) SearchWithChangelog(jql string, maxResults int) (map[string]any, error) { - path := fmt.Sprintf("/rest/api/3/search/jql?jql=%s&maxResults=%d&fields=%s&expand=changelog", - urlEncode(jql), maxResults, + path := fmt.Sprintf("%s?jql=%s&maxResults=%d&fields=%s&expand=changelog", + c.strategy.SearchPath(), urlEncode(jql), maxResults, "key,summary,status,issuetype,created,updated") return c.getJSON(path) } @@ -391,12 +373,12 @@ func (c *Client) MoveIssuesToSprint(sprintID int, issueKeys []string) error { // ListProjects returns all accessible projects. func (c *Client) ListProjects() ([]map[string]any, error) { - return c.getJSONArray("/rest/api/3/project") + return c.getJSONArray(c.strategy.APIPath("project")) } // GetProject fetches a single project by key. func (c *Client) GetProject(projectKey string) (map[string]any, error) { - path := fmt.Sprintf("/rest/api/3/project/%s", projectKey) + path := c.strategy.APIPath(fmt.Sprintf("project/%s", projectKey)) return c.getJSON(path) } @@ -404,13 +386,13 @@ func (c *Client) GetProject(projectKey string) (map[string]any, error) { // ListUsers searches for users by query string. func (c *Client) ListUsers(query string) ([]map[string]any, error) { - path := fmt.Sprintf("/rest/api/3/user/search?query=%s", query) + path := c.strategy.UserSearchPath(query) return c.getJSONArray(path) } // GetUser fetches a user by account ID. func (c *Client) GetUser(accountID string) (map[string]any, error) { - path := fmt.Sprintf("/rest/api/3/user?accountId=%s", accountID) + path := c.strategy.UserPath(accountID) return c.getJSON(path) } diff --git a/internal/jira/client_test.go b/internal/jira/client_test.go index fa7ef90..51cd2c8 100644 --- a/internal/jira/client_test.go +++ b/internal/jira/client_test.go @@ -18,6 +18,12 @@ func newTestServer(handler http.HandlerFunc) (*httptest.Server, *jira.Client) { return server, client } +func newServerTestServer(handler http.HandlerFunc) (*httptest.Server, *jira.Client) { + server := httptest.NewServer(handler) + client, _ := jira.NewClient(server.URL, "", "test-token", "pat", 5) + return server, client +} + func TestNewClient(t *testing.T) { t.Run("valid", func(t *testing.T) { c, err := jira.NewClient("https://jira.example.com", "user@example.com", "token", "basic", 10) @@ -187,6 +193,7 @@ func TestCreateIssue(t *testing.T) { project := fields["project"].(map[string]any) assert.Equal(t, "TEST", project["key"]) assert.Equal(t, "Fix bug", fields["summary"]) + assert.IsType(t, map[string]any{}, fields["description"]) w.Header().Set("Content-Type", "application/json") w.WriteHeader(201) @@ -202,6 +209,41 @@ func TestCreateIssue(t *testing.T) { assert.Equal(t, "TEST-42", data["key"]) } +func TestServerClientUsesRESTAPI2AndWikiBody(t *testing.T) { + server, client := newServerTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/issue", r.URL.Path) + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization")) + + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + fields := body["fields"].(map[string]any) + assert.Equal(t, "Description", fields["description"]) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(201) + _ = json.NewEncoder(w).Encode(map[string]any{"key": "TEST-42"}) + }) + defer server.Close() + + data, err := client.CreateIssue("TEST", "Fix bug", "Bug", "Description", "High", nil, "", nil, nil) + require.NoError(t, err) + assert.Equal(t, "TEST-42", data["key"]) +} + +func TestServerClientUsesLegacySearchEndpoint(t *testing.T) { + server, client := newServerTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/2/search", r.URL.Path) + assert.Equal(t, "project = TEST", r.URL.Query().Get("jql")) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"total": 0, "issues": []any{}}) + }) + defer server.Close() + + _, err := client.Search("project = TEST", 0, 25) + require.NoError(t, err) +} + func TestDeleteIssue(t *testing.T) { server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/rest/api/3/issue/TEST-1", r.URL.Path) diff --git a/internal/jira/strategy.go b/internal/jira/strategy.go new file mode 100644 index 0000000..1c17caf --- /dev/null +++ b/internal/jira/strategy.go @@ -0,0 +1,73 @@ +package jira + +import "fmt" + +// Strategy describes the Jira REST API dialect used by a client. +// Jira Cloud uses REST API v3 and ADF document bodies. Jira Server/Data Center +// uses REST API v2 and wiki/plain string bodies. +type Strategy interface { + APIPath(resource string) string + SearchPath() string + TextBody(text string) any + CommentBody(text string) any + AssignBody(accountID, name string) map[string]any + UserSearchPath(query string) string + UserPath(accountID string) string +} + +type cloudStrategy struct{} + +func (cloudStrategy) APIPath(resource string) string { return "/rest/api/3/" + resource } +func (cloudStrategy) SearchPath() string { return "/rest/api/3/search/jql" } +func (cloudStrategy) TextBody(text string) any { return textToADF(text) } +func (cloudStrategy) CommentBody(text string) any { return textToADF(text) } +func (cloudStrategy) AssignBody(accountID, name string) map[string]any { + body := map[string]any{} + switch { + case accountID != "": + body["accountId"] = accountID + case name != "": + body["name"] = name + default: + body["accountId"] = nil + } + return body +} +func (cloudStrategy) UserSearchPath(query string) string { + return fmt.Sprintf("/rest/api/3/user/search?query=%s", urlEncode(query)) +} +func (cloudStrategy) UserPath(accountID string) string { + return fmt.Sprintf("/rest/api/3/user?accountId=%s", urlEncode(accountID)) +} + +type serverStrategy struct{} + +func (serverStrategy) APIPath(resource string) string { return "/rest/api/2/" + resource } +func (serverStrategy) SearchPath() string { return "/rest/api/2/search" } +func (serverStrategy) TextBody(text string) any { return text } +func (serverStrategy) CommentBody(text string) any { return text } +func (serverStrategy) AssignBody(accountID, name string) map[string]any { + body := map[string]any{} + switch { + case name != "": + body["name"] = name + case accountID != "": + body["name"] = accountID + default: + body["name"] = nil + } + return body +} +func (serverStrategy) UserSearchPath(query string) string { + return fmt.Sprintf("/rest/api/2/user/search?username=%s", urlEncode(query)) +} +func (serverStrategy) UserPath(accountID string) string { + return fmt.Sprintf("/rest/api/2/user?username=%s", urlEncode(accountID)) +} + +func selectStrategy(_ string, authType string) Strategy { + if authType == "pat" || authType == "bearer" { + return serverStrategy{} + } + return cloudStrategy{} +} From ac74376f0b39b4f815bdb33479cd048c75b8b542 Mon Sep 17 00:00:00 2001 From: Anders Aaen Springborg Date: Tue, 9 Jun 2026 18:29:18 +0200 Subject: [PATCH 6/6] feat: enhance issue transition with updates and add comment/assignee formatting --- README.md | 15 +++--- internal/jira/client.go | 21 ++++++++ internal/jira/client_test.go | 24 ++++++++++ internal/jira/strategy.go | 13 +++++ pkg/cmd/issue/assign.go | 61 ++++++++++++++++++++++-- pkg/cmd/issue/clone.go | 7 ++- pkg/cmd/issue/comment.go | 36 ++++++++++---- pkg/cmd/issue/create.go | 8 ++-- pkg/cmd/issue/delete.go | 5 +- pkg/cmd/issue/edit.go | 8 ++-- pkg/cmd/issue/improvements_test.go | 55 +++++++++++++++++++++ pkg/cmd/issue/link.go | 7 ++- pkg/cmd/issue/move.go | 26 +++++++--- pkg/cmd/issue/output.go | 7 +++ pkg/cmd/issue/unlink.go | 7 ++- pkg/cmd/issue/view.go | 35 +++++++++----- skills/jira-cli/SKILL.md | 8 ++-- skills/jira-cli/references/auth-setup.md | 8 +--- skills/jira-cli/references/workflows.md | 7 ++- 19 files changed, 294 insertions(+), 64 deletions(-) create mode 100644 pkg/cmd/issue/improvements_test.go create mode 100644 pkg/cmd/issue/output.go diff --git a/README.md b/README.md index 6da08b6..c413928 100644 --- a/README.md +++ b/README.md @@ -69,8 +69,9 @@ jira auth login --server https://your-org.atlassian.net \ The token is stored in the OS keychain — never written to disk. -> **Jira Cloud only (for now).** All API calls target the Cloud REST v3 API, so -> Jira Server / Data Center instances are not yet supported. Support is planned. +> **Jira Cloud and Server/Data Center.** Cloud profiles (`*.atlassian.net`) use +> basic auth + REST v3 + ADF bodies. Server/Data Center profiles use PAT/bearer +> auth + REST v2 + wiki/plain text bodies. ### 2. Verify and set defaults @@ -107,7 +108,7 @@ Set a persistent default with `jira context set --display markdown`; `--format` Every mutation is a single command with explicit flags, so the transcript line is exactly what changed. Write commands print the result as JSON and exit non-zero on failure, so you can chain them with `&&` and check the exit code. ```bash -# Create (add --raw to get the new issue as JSON, e.g. to read .key) +# Create (prints structured JSON with .key; add --raw for the full Jira response) jira issue create -p PROJ -s "Fix login bug" -t Bug -b "Steps to reproduce..." # Edit fields, including custom fields by id (-F is repeatable) @@ -117,8 +118,8 @@ jira issue edit PROJ-123 -s "New summary" -l backend -F customfield_10145="value jira issue move PROJ-123 "In Progress" jira issue move PROJ-123 Done --resolution Fixed --comment "Shipped in v1.2" -# Assign and comment ('me' for yourself, 'x' to unassign; otherwise an account ID -# from `jira user search`) +# Assign and comment ('me' for yourself, 'x' to unassign; otherwise pass an account ID, +# username, or email resolvable by `jira user search`) jira issue assign PROJ-123 me jira issue comment add PROJ-123 "Investigated -- root cause was a stale cache." @@ -134,7 +135,7 @@ jira sprint add 42 PROJ-123 PROJ-456 Capture a created key and act on it in the same script: ```bash -key=$(jira issue create -p PROJ -s "Automated task" -t Task --raw | jq -r '.key') +key=$(jira issue create -p PROJ -s "Automated task" -t Task | jq -r '.key') jira issue move "$key" "In Progress" && jira issue assign "$key" me ``` @@ -206,7 +207,7 @@ jira issue view CER-1 --raw | rg -o '"customfield_\d+"' ``` ### Non-mutating discovery and field projection -Read paths (`search jql`, `search text`, `issue list`, `mine`, `issue view`) are non-mutating and return consistent, keyed JSON (`key`, `fields.*`). Custom-field projection keeps responses small so an agent retrieves only what it needs: `search`, `list`, and `mine` take repeatable `--field`/`-F` flags; `issue view` takes a comma-separated `--fields`. +Read paths (`search jql`, `search text`, `issue list`, `mine`, `issue view`) are non-mutating and return consistent, keyed JSON (`key`, `fields.*`). Custom-field projection keeps responses small so an agent retrieves only what it needs via repeatable `--field`/`-F` flags; `issue view` also keeps `--fields` as a comma-separated alias. ### CLI over MCP Rather than running a Model Context Protocol server, this is a standard binary: no long-lived daemon, no open socket, no persistent state between executions. Every action is the exact execution string, so debugging is just re-running the command from shell history, and agents reuse shell operators (`&&`, `||`, `|`, `>`) instead of learning a custom RPC protocol. diff --git a/internal/jira/client.go b/internal/jira/client.go index e3034af..f68abe7 100644 --- a/internal/jira/client.go +++ b/internal/jira/client.go @@ -214,10 +214,31 @@ func (c *Client) GetIssueTransitions(key string) ([]map[string]any, error) { // TransitionIssue moves an issue to a new status via transition ID. func (c *Client) TransitionIssue(key, transitionID string) error { + return c.TransitionIssueWithUpdates(key, transitionID, nil, nil) +} + +// CommentFieldValue formats a comment body for the selected Jira API strategy. +func (c *Client) CommentFieldValue(body string) any { + return c.strategy.CommentBody(body) +} + +// AssigneeFieldValue formats an assignee field for the selected Jira API strategy. +func (c *Client) AssigneeFieldValue(accountID, name string) map[string]any { + return c.strategy.AssigneeField(accountID, name) +} + +// TransitionIssueWithUpdates moves an issue and optionally sets fields/update operations. +func (c *Client) TransitionIssueWithUpdates(key, transitionID string, fields, update map[string]any) error { path := c.strategy.APIPath(fmt.Sprintf("issue/%s/transitions", key)) body := map[string]any{ "transition": map[string]any{"id": transitionID}, } + if len(fields) > 0 { + body["fields"] = fields + } + if len(update) > 0 { + body["update"] = update + } _, err := c.postJSON(path, body) return err } diff --git a/internal/jira/client_test.go b/internal/jira/client_test.go index 51cd2c8..7a8aab7 100644 --- a/internal/jira/client_test.go +++ b/internal/jira/client_test.go @@ -504,6 +504,30 @@ func TestBasicAuth(t *testing.T) { assert.NoError(t, err) } +func TestTransitionIssueWithUpdates(t *testing.T) { + server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/rest/api/3/issue/TEST-1/transitions", r.URL.Path) + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, map[string]any{"id": "31"}, body["transition"]) + fields := body["fields"].(map[string]any) + assert.Equal(t, map[string]any{"name": "Fixed"}, fields["resolution"]) + update := body["update"].(map[string]any) + assert.Contains(t, update, "comment") + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(204) + }) + defer server.Close() + + err := client.TransitionIssueWithUpdates( + "TEST-1", + "31", + map[string]any{"resolution": map[string]any{"name": "Fixed"}}, + map[string]any{"comment": []map[string]any{{"add": map[string]any{"body": client.CommentFieldValue("done")}}}}, + ) + require.NoError(t, err) +} + func TestAssignIssue(t *testing.T) { t.Run("assign by account ID", func(t *testing.T) { server, client := newTestServer(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/jira/strategy.go b/internal/jira/strategy.go index 1c17caf..cd933b4 100644 --- a/internal/jira/strategy.go +++ b/internal/jira/strategy.go @@ -11,6 +11,7 @@ type Strategy interface { TextBody(text string) any CommentBody(text string) any AssignBody(accountID, name string) map[string]any + AssigneeField(accountID, name string) map[string]any UserSearchPath(query string) string UserPath(accountID string) string } @@ -33,6 +34,12 @@ func (cloudStrategy) AssignBody(accountID, name string) map[string]any { } return body } +func (cloudStrategy) AssigneeField(accountID, name string) map[string]any { + if accountID != "" { + return map[string]any{"accountId": accountID} + } + return map[string]any{"accountId": name} +} func (cloudStrategy) UserSearchPath(query string) string { return fmt.Sprintf("/rest/api/3/user/search?query=%s", urlEncode(query)) } @@ -58,6 +65,12 @@ func (serverStrategy) AssignBody(accountID, name string) map[string]any { } return body } +func (serverStrategy) AssigneeField(accountID, name string) map[string]any { + if name != "" { + return map[string]any{"name": name} + } + return map[string]any{"name": accountID} +} func (serverStrategy) UserSearchPath(query string) string { return fmt.Sprintf("/rest/api/2/user/search?username=%s", urlEncode(query)) } diff --git a/pkg/cmd/issue/assign.go b/pkg/cmd/issue/assign.go index 203b968..507f877 100644 --- a/pkg/cmd/issue/assign.go +++ b/pkg/cmd/issue/assign.go @@ -9,6 +9,40 @@ import ( "github.com/spf13/cobra" ) +type userSearcher interface { + ListUsers(query string) ([]map[string]any, error) +} + +func resolveAssignmentUser(client userSearcher, user string) (accountID, name string) { + users, err := client.ListUsers(user) + if err != nil || len(users) == 0 { + return user, user + } + + selected := users[0] + for _, candidate := range users { + if strings.EqualFold(stringField(candidate, "emailAddress"), user) || strings.EqualFold(stringField(candidate, "name"), user) { + selected = candidate + break + } + } + + accountID = stringField(selected, "accountId") + name = stringField(selected, "name") + if accountID == "" { + accountID = user + } + if name == "" { + name = user + } + return accountID, name +} + +func stringField(m map[string]any, key string) string { + v, _ := m[key].(string) + return v +} + func newAssignCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "assign ", @@ -36,13 +70,19 @@ func newAssignCmd(f *cmdutil.Factory) *cobra.Command { if err := client.AssignIssue(issueKey, "", "", ""); err != nil { return err } - return driver.Message("Unassigned issue: %s", issueKey) + return writeMutationResult(driver, map[string]any{ + "status": "unassigned", + "key": issueKey, + }) case "default": if err := client.AssignIssue(issueKey, "-1", "", ""); err != nil { return err } - return driver.Message("Assigned issue %s to default assignee", issueKey) + return writeMutationResult(driver, map[string]any{ + "status": "assigned_default", + "key": issueKey, + }) case "me": data, err := client.GetMyself() @@ -60,13 +100,24 @@ func newAssignCmd(f *cmdutil.Factory) *cobra.Command { if displayName == "" { displayName = accountID } - return driver.Message("Assigned issue %s to %s (me)", issueKey, displayName) + return writeMutationResult(driver, map[string]any{ + "status": "assigned", + "key": issueKey, + "user": displayName, + "accountId": accountID, + "current_user": true, + }) default: - if err := client.AssignIssue(issueKey, user, user, ""); err != nil { + accountID, name := resolveAssignmentUser(client, user) + if err := client.AssignIssue(issueKey, accountID, name, ""); err != nil { return err } - return driver.Message("Assigned issue %s to %s", issueKey, user) + return writeMutationResult(driver, map[string]any{ + "status": "assigned", + "key": issueKey, + "user": user, + }) } }, } diff --git a/pkg/cmd/issue/clone.go b/pkg/cmd/issue/clone.go index 488582a..20b39f6 100644 --- a/pkg/cmd/issue/clone.go +++ b/pkg/cmd/issue/clone.go @@ -59,7 +59,12 @@ func newCloneCmd(f *cmdutil.Factory) *cobra.Command { return driver.Raw(data) } - return driver.Message("Cloned %s to %v", issueKey, data["key"]) + return writeMutationResult(driver, map[string]any{ + "status": "cloned", + "source": issueKey, + "key": data["key"], + "id": data["id"], + }) }, } diff --git a/pkg/cmd/issue/comment.go b/pkg/cmd/issue/comment.go index 74f02a9..3af82eb 100644 --- a/pkg/cmd/issue/comment.go +++ b/pkg/cmd/issue/comment.go @@ -2,6 +2,7 @@ package issue import ( "fmt" + "os" "strings" "AndersSpringborg/jira-cli/internal/cmdutil" @@ -19,17 +20,11 @@ func newCommentAddCmd(f *cmdutil.Factory) *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { issueKey := strings.ToUpper(args[0]) - var body string - if len(args) > 1 { - body = args[1] - } - - if body == "" { - return fmt.Errorf("comment body is required (pass as argument or use --template)") + body, err := resolveCommentBody(args[1:], template) + if err != nil { + return err } - _ = template - client, err := f.LoadClient() if err != nil { return err @@ -40,7 +35,10 @@ func newCommentAddCmd(f *cmdutil.Factory) *cobra.Command { } driver := f.DisplayDriver(cmd) - return driver.Message("Added comment to: %s", issueKey) + return writeMutationResult(driver, map[string]any{ + "status": "comment_added", + "key": issueKey, + }) }, } @@ -49,6 +47,24 @@ func newCommentAddCmd(f *cmdutil.Factory) *cobra.Command { return cmd } +func resolveCommentBody(args []string, template string) (string, error) { + if len(args) > 0 && args[0] != "" { + return args[0], nil + } + if template == "" { + return "", fmt.Errorf("comment body is required (pass as argument or use --template)") + } + data, err := os.ReadFile(template) + if err != nil { + return "", fmt.Errorf("read comment template: %w", err) + } + body := strings.TrimSpace(string(data)) + if body == "" { + return "", fmt.Errorf("comment template %q is empty", template) + } + return body, nil +} + func newCommentCmd(f *cmdutil.Factory) *cobra.Command { cmd := &cobra.Command{ Use: "comment", diff --git a/pkg/cmd/issue/create.go b/pkg/cmd/issue/create.go index 01c89ae..568937c 100644 --- a/pkg/cmd/issue/create.go +++ b/pkg/cmd/issue/create.go @@ -19,7 +19,6 @@ func newCreateCmd(f *cmdutil.Factory) *cobra.Command { parent string fixVersions []string components []string - noInput bool rawOutput bool ) @@ -62,7 +61,11 @@ func newCreateCmd(f *cmdutil.Factory) *cobra.Command { return driver.Raw(data) } - return driver.Message("Created issue: %v", data["key"]) + return writeMutationResult(driver, map[string]any{ + "status": "created", + "key": data["key"], + "id": data["id"], + }) }, } @@ -75,7 +78,6 @@ func newCreateCmd(f *cmdutil.Factory) *cobra.Command { cmd.Flags().StringVarP(&parent, "parent", "P", "", "Parent issue key (epic)") cmd.Flags().StringSliceVarP(&components, "component", "C", nil, "Component (repeatable)") cmd.Flags().StringSliceVar(&fixVersions, "fix-version", nil, "Fix version (repeatable)") - cmd.Flags().BoolVar(&noInput, "no-input", false, "Disable interactive prompt") cmd.Flags().BoolVar(&rawOutput, "raw", false, "Print raw JSON") return cmd diff --git a/pkg/cmd/issue/delete.go b/pkg/cmd/issue/delete.go index 7e54154..636d6ca 100644 --- a/pkg/cmd/issue/delete.go +++ b/pkg/cmd/issue/delete.go @@ -26,7 +26,10 @@ func newDeleteCmd(f *cmdutil.Factory) *cobra.Command { } driver := f.DisplayDriver(cmd) - return driver.Message("Deleted issue: %s", issueKey) + return writeMutationResult(driver, map[string]any{ + "status": "deleted", + "key": issueKey, + }) }, } diff --git a/pkg/cmd/issue/edit.go b/pkg/cmd/issue/edit.go index 5e51b0c..7c6b609 100644 --- a/pkg/cmd/issue/edit.go +++ b/pkg/cmd/issue/edit.go @@ -19,7 +19,6 @@ func newEditCmd(f *cmdutil.Factory) *cobra.Command { components []string fixVersions []string customFields []string - noInput bool ) cmd := &cobra.Command{ @@ -101,7 +100,10 @@ with a - prefix: --label bugfix --label -wontfix`, } driver := f.DisplayDriver(cmd) - return driver.Message("Updated issue: %s", issueKey) + return writeMutationResult(driver, map[string]any{ + "status": "updated", + "key": issueKey, + }) }, } @@ -112,8 +114,6 @@ with a - prefix: --label bugfix --label -wontfix`, cmd.Flags().StringSliceVarP(&components, "component", "C", nil, "Component (prefix - to remove, repeatable)") cmd.Flags().StringSliceVar(&fixVersions, "fix-version", nil, "Fix version (prefix - to remove, repeatable)") cmd.Flags().StringArrayVarP(&customFields, "field", "F", nil, `Custom field as key=value (e.g. customfield_10001=5), repeatable`) - cmd.Flags().BoolVar(&noInput, "no-input", false, "Disable interactive prompt") - _ = noInput return cmd } diff --git a/pkg/cmd/issue/improvements_test.go b/pkg/cmd/issue/improvements_test.go new file mode 100644 index 0000000..d05fb3e --- /dev/null +++ b/pkg/cmd/issue/improvements_test.go @@ -0,0 +1,55 @@ +package issue + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubUserSearcher struct { + users []map[string]any + err error +} + +func (s stubUserSearcher) ListUsers(string) ([]map[string]any, error) { + return s.users, s.err +} + +func TestResolveCommentBody(t *testing.T) { + t.Run("uses positional body before template", func(t *testing.T) { + got, err := resolveCommentBody([]string{"inline"}, "missing.md") + require.NoError(t, err) + assert.Equal(t, "inline", got) + }) + + t.Run("loads template", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "comment.md") + require.NoError(t, os.WriteFile(path, []byte("\nfrom template\n"), 0o600)) + got, err := resolveCommentBody(nil, path) + require.NoError(t, err) + assert.Equal(t, "from template", got) + }) + + t.Run("requires body or template", func(t *testing.T) { + _, err := resolveCommentBody(nil, "") + require.Error(t, err) + }) +} + +func TestParseViewFields(t *testing.T) { + got := parseViewFields("summary, status", []string{"customfield_1", " labels "}) + assert.Equal(t, []string{"summary", "status", "customfield_1", "labels"}, got) +} + +func TestResolveAssignmentUser(t *testing.T) { + searcher := stubUserSearcher{users: []map[string]any{ + {"accountId": "wrong", "emailAddress": "other@example.com", "name": "other"}, + {"accountId": "abc123", "emailAddress": "alice@example.com", "name": "alice"}, + }} + accountID, name := resolveAssignmentUser(searcher, "alice@example.com") + assert.Equal(t, "abc123", accountID) + assert.Equal(t, "alice", name) +} diff --git a/pkg/cmd/issue/link.go b/pkg/cmd/issue/link.go index c3febef..027f5d9 100644 --- a/pkg/cmd/issue/link.go +++ b/pkg/cmd/issue/link.go @@ -28,7 +28,12 @@ func newLinkCmd(f *cmdutil.Factory) *cobra.Command { } driver := f.DisplayDriver(cmd) - return driver.Message("Linked %s %s %s", issue1, linkType, issue2) + return writeMutationResult(driver, map[string]any{ + "status": "linked", + "inwardIssue": issue1, + "outwardIssue": issue2, + "type": linkType, + }) }, } diff --git a/pkg/cmd/issue/move.go b/pkg/cmd/issue/move.go index 73d9816..b6fdc93 100644 --- a/pkg/cmd/issue/move.go +++ b/pkg/cmd/issue/move.go @@ -70,15 +70,29 @@ func newMoveCmd(f *cmdutil.Factory) *cobra.Command { return fmt.Errorf("invalid transition: %s", targetState) } - if err := client.TransitionIssue(issueKey, transitionID); err != nil { - return err + fields := map[string]any{} + update := map[string]any{} + if resolution != "" { + fields["resolution"] = map[string]any{"name": resolution} + } + if assignee != "" { + accountID, name := resolveAssignmentUser(client, assignee) + fields["assignee"] = client.AssigneeFieldValue(accountID, name) + } + if comment != "" { + update["comment"] = []map[string]any{{"add": map[string]any{"body": client.CommentFieldValue(comment)}}} } - _ = comment - _ = resolution - _ = assignee + if err := client.TransitionIssueWithUpdates(issueKey, transitionID, fields, update); err != nil { + return err + } - return driver.Message("Transitioned %s to %s", issueKey, targetState) + return writeMutationResult(driver, map[string]any{ + "status": "transitioned", + "key": issueKey, + "transition": targetState, + "transitionId": transitionID, + }) }, } diff --git a/pkg/cmd/issue/output.go b/pkg/cmd/issue/output.go new file mode 100644 index 0000000..87070b1 --- /dev/null +++ b/pkg/cmd/issue/output.go @@ -0,0 +1,7 @@ +package issue + +import "AndersSpringborg/jira-cli/internal/output" + +func writeMutationResult(driver output.DisplayDriver, data map[string]any) error { + return driver.Item("Result", data) +} diff --git a/pkg/cmd/issue/unlink.go b/pkg/cmd/issue/unlink.go index 35675ec..a9858d5 100644 --- a/pkg/cmd/issue/unlink.go +++ b/pkg/cmd/issue/unlink.go @@ -40,7 +40,12 @@ func newUnlinkCmd(f *cmdutil.Factory) *cobra.Command { return err } driver := f.DisplayDriver(cmd) - return driver.Message("Unlinked %s and %s", issue1, issue2) + return writeMutationResult(driver, map[string]any{ + "status": "unlinked", + "issue1": issue1, + "issue2": issue2, + "linkId": linkID, + }) } } diff --git a/pkg/cmd/issue/view.go b/pkg/cmd/issue/view.go index 7edbabf..2bddf89 100644 --- a/pkg/cmd/issue/view.go +++ b/pkg/cmd/issue/view.go @@ -8,9 +8,29 @@ import ( "github.com/spf13/cobra" ) +func parseViewFields(commaSeparated string, repeatable []string) []string { + fields := make([]string, 0, len(repeatable)) + if commaSeparated != "" { + for _, fld := range strings.Split(commaSeparated, ",") { + fld = strings.TrimSpace(fld) + if fld != "" { + fields = append(fields, fld) + } + } + } + for _, fld := range repeatable { + fld = strings.TrimSpace(fld) + if fld != "" { + fields = append(fields, fld) + } + } + return fields +} + func newViewCmd(f *cmdutil.Factory) *cobra.Command { var ( fields string + fieldList []string comments int rawOutput bool ) @@ -30,22 +50,14 @@ func newViewCmd(f *cmdutil.Factory) *cobra.Command { driver := f.DisplayDriver(cmd) - var fieldList []string - if fields != "" { - for _, fld := range strings.Split(fields, ",") { - fld = strings.TrimSpace(fld) - if fld != "" { - fieldList = append(fieldList, fld) - } - } - } + requestedFields := parseViewFields(fields, fieldList) // Request comments if the user asked for them. if comments > 0 { - fieldList = append(fieldList, "comment") + requestedFields = append(requestedFields, "comment") } - data, err := client.GetIssue(issueKey, fieldList) + data, err := client.GetIssue(issueKey, requestedFields) if err != nil { return err } @@ -90,6 +102,7 @@ func newViewCmd(f *cmdutil.Factory) *cobra.Command { } cmd.Flags().StringVar(&fields, "fields", "", "Comma-separated fields to fetch") + cmd.Flags().StringArrayVarP(&fieldList, "field", "F", nil, "Field to fetch (repeatable)") cmd.Flags().IntVar(&comments, "comments", 0, "Number of recent comments to display") cmd.Flags().BoolVar(&rawOutput, "raw", false, "Print raw JSON") diff --git a/skills/jira-cli/SKILL.md b/skills/jira-cli/SKILL.md index 3ad5a28..d2d1160 100644 --- a/skills/jira-cli/SKILL.md +++ b/skills/jira-cli/SKILL.md @@ -5,7 +5,7 @@ description: Drive Jira from the shell via the `jira` CLI - read, create, edit, # jira-cli -Non-interactive CLI for Jira Cloud. JSON by default, markdown on request, designed to be driven by an agent. (Jira Server / Data Center is not yet supported — all calls use the Cloud REST v3 API.) +Non-interactive CLI for Jira Cloud and Jira Server/Data Center. JSON by default, markdown on request, designed to be driven by an agent. Cloud uses REST v3 + ADF; Server/Data Center uses REST v2 + wiki/plain text bodies. **Prerequisite:** the `jira` binary must be installed (`npm install -g @888aaen/jira-cli`) and authenticated. If `jira ping` fails, see `references/auth-setup.md`. @@ -44,15 +44,15 @@ Reads (`list`, `view`, `search`, `me`/`mine`, `ping`, board/project/user queries | Intent | Command | |-----------------------------------|---------------------------------------------------------------| -| List issues in current project | `jira issue list` | +| List issues in current project | `jira issue list` (alias `ls`) | | List issues assigned to me | `jira mine` (alias `my`); `--all` includes done | -| View an issue | `jira issue view PROJ-123` | +| View an issue | `jira issue view PROJ-123` (alias `get`; supports `-F`) | | Create an issue | `jira issue create -p PROJ -s "Summary" -t Bug [-b "body"] [-y High]` | | Edit summary / labels / etc. | `jira issue edit PROJ-123 -s "New summary"` (alias `update`) | | Edit a custom field | `jira issue edit PROJ-123 --field customfield_10016=5` | | Delete an issue | `jira issue delete PROJ-123` | | Assign to me | `jira issue assign PROJ-123 me` (`x` to unassign) | -| Assign to a user | `jira issue assign PROJ-123 ` | +| Assign to a user | `jira issue assign PROJ-123 ` | | Transition status | `jira issue move PROJ-123 "In Progress"` (alias `transition`, case-insensitive) | | Comment | `jira issue comment add PROJ-123 "text"` | | Link / unlink | `jira issue link PROJ-1 PROJ-2 Blocks`, `jira issue unlink PROJ-1 PROJ-2` | diff --git a/skills/jira-cli/references/auth-setup.md b/skills/jira-cli/references/auth-setup.md index 5e9e493..cfccf8a 100644 --- a/skills/jira-cli/references/auth-setup.md +++ b/skills/jira-cli/references/auth-setup.md @@ -4,7 +4,7 @@ The auth type is auto-detected from the base URL: `*.atlassian.net` -> `basic` (email + API token), anything else -> `pat` (Personal Access Token). Tokens live in the OS keychain - never on disk. -> **Jira Cloud only (for now).** All API calls target the Cloud REST v3 API, so Jira Server / Data Center is not yet functional even though PAT login is accepted. Support is planned. Use a Cloud (`*.atlassian.net`) instance. +Cloud profiles use REST v3 + ADF bodies. Jira Server / Data Center profiles use REST v2 + wiki/plain text bodies. ## Jira Cloud (`*.atlassian.net`) @@ -19,11 +19,7 @@ jira auth login \ --token API_TOKEN ``` -## Jira Server / Data Center (PAT) — not yet supported - -PAT login is accepted, but API calls currently target the Cloud v3 endpoints, so -requests against a Server / Data Center instance will fail. Documented here for when -support lands. +## Jira Server / Data Center (PAT) 1. User creates a Personal Access Token under Profile -> Personal Access Tokens. 2. Initialize and log in: diff --git a/skills/jira-cli/references/workflows.md b/skills/jira-cli/references/workflows.md index 59785b6..e1c53c3 100644 --- a/skills/jira-cli/references/workflows.md +++ b/skills/jira-cli/references/workflows.md @@ -5,15 +5,14 @@ The mutating steps below (create, assign, edit, move, comment, clone, bulk trans ## Full issue lifecycle ```bash -# 1. Create (--raw prints the JSON payload incl. .key; default prints a message) +# 1. Create (default output includes .key; --raw prints the full Jira response) jira issue create \ -p PROJ \ -s "Fix login timeout" \ -t Bug \ -b "Users see timeout after 30s" \ - -y High \ - --raw -# -> { "key": "PROJ-456", ... } + -y High +# -> { "status": "created", "key": "PROJ-456", ... } # 2. Assign to me jira issue assign PROJ-456 me