Jira Automation skill for Codex
Bulk-edit Jira safely, from JQL to proof. Codex scripts Jira Cloud the way it currently works: JQL through /search/jql with token paging, descriptions in Atlassian Document Format, users by accountId and custom fields resolved by ID. Bulk changes run as a dry run, then one issue, then the rest, with a log and a re-run of the JQL as proof. It also designs Automation rules that cannot loop and debugs smart values from the audit log.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- jira-automation Installs to ~/.agents/skills/jira-automation/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install jira-automation The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project jira-automation Installs to .agents/skills/jira-automation/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/jira-automation.zipunzip -o jira-automation.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) jira-automation PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $jira-automation, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Automates Jira Cloud through the REST API v3 and Jira Automation rules - JQL search with the /search/jql endpoint and nextPageToken paging, creating and editing issues with Atlassian Document Format, transitions, custom field mapping, bulk operations, webhooks and smart-value rules. Use when asked to script Jira tickets, sync issues from another tool, bulk-update or transition issues, build a Jira report from JQL, or design and debug an automation rule.
Use it when
- You need to create, edit, transition or report on Jira Cloud issues from code
- A bulk change across many tickets must be scoped and reversible
- An existing script broke after the old /rest/api/3/search endpoint was removed
- You are writing or debugging a Jira Automation rule with smart values
Use something else when
- The team tracks work in Linear: use linear-api
- You need GitHub issues or PRs automated: use github-api
How to check the result
Re-run the scoping JQL with the target state added and compare counts, then check the changelog of two changed issues for the expected author and values.
The mistake it prevents
Passing plain strings to description or comment bodies in REST v3; Cloud expects Atlassian Document Format and rejects or mangles anything else.
Done well means: every write is scoped by a JQL query you ran first and showed to the user,
issues are identified by key and users by accountId, custom fields are resolved by ID, and
a bulk change is tried on one issue before it touches hundreds.
Pick the mechanism
| Need | Use | Why |
|---|---|---|
| React to Jira events inside Jira (assign, transition, comment) | Automation rule | No hosting, audit log, runs as a rule actor |
| One-off or scheduled bulk change, report, migration | REST API script | Version control, dry runs, retries |
| Push events to your service | Admin webhook (site) or dynamic webhook (Connect/OAuth app) | Near real time |
| Logic that lives inside Jira UI for many sites | Forge app | Hosted by Atlassian, scoped permissions |
| Jira Data Center / Server | REST API v2 with a personal access token | v3 and ADF are Cloud only |
Before touching anything
- Read
AGENTS.mdfor where credentials live and which HTTP client the repo uses. - Confirm site URL (
https://<site>.atlassian.net), Cloud vs Data Center, and the project keys. - Credentials: for scripts, an API token with the user’s email (Basic auth). For apps, OAuth 2.0
(3LO) against
https://api.atlassian.com/ex/jira/<cloudId>/rest/api/3/.... Never hard-code tokens; read them from env (JIRA_EMAIL,JIRA_API_TOKEN). - Calls need network. In a Codex sandbox with network off, write the script and ask the user to run it or approve network access.
curl -s -u "$JIRA_EMAIL:$JIRA_API_TOKEN" -H "Accept: application/json" \
"https://$JIRA_SITE.atlassian.net/rest/api/3/myself" | jq '{accountId, displayName}'
Search: JQL with token paging
The old /rest/api/3/search (startAt/total) was removed from Cloud in 2025. Use
/rest/api/3/search/jql, which pages with nextPageToken and returns only IDs unless you
ask for fields.
import os, time, requests
SITE = f"https://{os.environ['JIRA_SITE']}.atlassian.net"
S = requests.Session()
S.auth = (os.environ["JIRA_EMAIL"], os.environ["JIRA_API_TOKEN"])
S.headers.update({"Accept": "application/json", "Content-Type": "application/json"})
def call(method, path, **kw):
for attempt in range(6):
r = S.request(method, SITE + path, timeout=30, **kw)
if r.status_code == 429 or r.status_code >= 500:
time.sleep(float(r.headers.get("Retry-After", 2 ** attempt)))
continue
r.raise_for_status()
return r.json() if r.content else None
raise RuntimeError(f"gave up on {method} {path}")
def search(jql, fields=("summary", "status", "assignee")):
token = None
while True:
body = {"jql": jql, "fields": list(fields), "maxResults": 100}
if token:
body["nextPageToken"] = token
page = call("POST", "/rest/api/3/search/jql", json=body)
yield from page.get("issues", [])
token = page.get("nextPageToken")
if not token:
break
- Need a count first?
POST /rest/api/3/search/approximate-countwith{"jql": ...}. It is approximate; do not use it to verify a bulk change, recount with a real search. - Always bound JQL:
project = OPS AND updated >= -30d. An unbounded JQL in a script is a full-site scan. - Prefer POST for search so long JQL is not URL-encoded into a 400.
Fields: resolve IDs, never guess
fields = call("GET", "/rest/api/3/field")
by_name = {f["name"]: f["id"] for f in fields} # "Story Points" -> "customfield_10016"
Names are not unique (two “Team” fields is common); when duplicates exist, show the user the
candidates with their schema and ask. For the fields an issue type requires on create:
GET /rest/api/3/issue/createmeta/OPS/issuetypes # list issue types and IDs
GET /rest/api/3/issue/createmeta/OPS/issuetypes/10001 # fields, required flags, allowed values
Create and edit issues
Text fields such as description and comment bodies are Atlassian Document Format (ADF) in v3,
not Markdown or plain strings.
def adf(text):
return {"type": "doc", "version": 1, "content": [
{"type": "paragraph", "content": [{"type": "text", "text": line}]}
for line in text.split("\n") if line.strip()
]}
issue = call("POST", "/rest/api/3/issue", json={"fields": {
"project": {"key": "OPS"},
"issuetype": {"id": "10001"},
"summary": "Rotate staging TLS certificate",
"description": adf("Cert expires 2026-11-30.\nOwner: platform team."),
"labels": ["certs"],
"assignee": {"accountId": "5b10ac8d82e05b22cc7d4ef5"},
"customfield_10016": 3,
}})
print(issue["key"])
| Operation | Endpoint | Note |
|---|---|---|
| Edit fields | PUT /rest/api/3/issue/{key} with fields or update | update supports add/remove on labels, components |
| Add comment | POST /rest/api/3/issue/{key}/comment with ADF body | |
| Change status | GET then POST /rest/api/3/issue/{key}/transitions | Status is not a writable field |
| Bulk create | POST /rest/api/3/issue/bulk | Up to 50 issues per call; check per-item errors |
| Link issues | POST /rest/api/3/issueLink | Type names from /rest/api/3/issueLinkType |
| Find a user | GET /rest/api/3/user/search?query= | Returns accountId; emails may be hidden by privacy settings |
Transitions are workflow-specific: fetch available ones per issue and match by target status name, because the transition ID differs between workflows.
def transition_to(key, status_name):
ts = call("GET", f"/rest/api/3/issue/{key}/transitions")["transitions"]
match = [t for t in ts if t["to"]["name"].lower() == status_name.lower()]
if not match:
raise ValueError(f"{key}: no transition to {status_name}; available {[t['to']['name'] for t in ts]}")
call("POST", f"/rest/api/3/issue/{key}/transitions", json={"transition": {"id": match[0]["id"]}})
Add ?notifyUsers=false on edits only when the user asks for silent bulk changes; it needs
admin rights and hides changes people may need to see.
Bulk change workflow
- [ ] 1. Write the JQL; run it read-only and show count + 5 sample keys
- [ ] 2. Print the planned change per issue (dry run, default mode of the script)
- [ ] 3. Get explicit user confirmation for the exact JQL and change
- [ ] 4. Apply to ONE issue, re-read it, check the change and the history
- [ ] 5. Apply to the rest at a steady rate, logging key -> result to a file
- [ ] 6. Re-run the JQL to prove the target state; list failures for retry
Deletes (DELETE /rest/api/3/issue/{key}) are irreversible. Never delete without the user
confirming the exact list; prefer a “Won’t do” transition or a label.
Automation rules
Structure: trigger -> conditions -> actions, optionally inside branches. Design rules so they are narrow, observable and cannot loop.
| Goal | Trigger | Condition | Action |
|---|---|---|---|
| Auto-assign bugs by component | Issue created | Issue type = Bug | Assign issue (by component lead or smart value) |
| Close stale waiting tickets | Scheduled, with JQL status = "Waiting for customer" AND updated <= -14d | - | Transition to Closed + comment |
| Parent done when all children done | Issue transitioned (to Done) | Branch: parent; JQL condition no open children | Transition parent |
| External system creates/updates issues | Incoming webhook | {{webhookData}} checks | Create or edit issue |
| Notify a service | Field value changed | Advanced compare | Send web request |
Smart values worth knowing: {{issue.key}}, {{issue.summary}},
{{issue.fields.customfield_10016}}, {{now.plusBusinessDays(3)}}, {{webhookData.ticket.id}},
{{#lookupIssues}}{{key}} {{/}} after a Lookup issues action. For the full catalog and debugging
patterns, read references/smart-values.md when writing or fixing a
rule that uses smart values or web requests.
Rule rules:
- Leave “Allow rule trigger” (rules triggering other rules) off unless the chain is designed; it is the usual cause of loops and runaway execution counts.
- Scheduled rules: use the JQL option so the rule runs per matching issue and skips when none.
- Rule actor: actions run as “Automation for Jira” by default; permissions of that actor decide what the rule can do. If an action silently does nothing, check project permissions first.
- Executions count against plan limits. A rule on “Issue updated” in a busy project burns them; prefer “Field value changed” with the specific field.
- Send web request: put secrets in the header, never in the URL; tick “Delay execution of subsequent rule actions until we’ve received a response” only when you use the response.
Webhooks
Site admin webhooks (Settings -> System -> WebHooks) are configured by an admin with a JQL
filter; they are not signed by default, so either add a secret query token checked server side
or terminate on a URL that is not guessable. OAuth/Connect apps register dynamic webhooks via
POST /rest/api/3/webhook; these expire after 30 days and must be refreshed with
PUT /rest/api/3/webhook/refresh. Process events idempotently by issue.id plus timestamp.
Gotchas
assignee: {"name": ...}fails in Cloud; onlyaccountIdworks.- Story points live in different custom fields per site (and team-managed vs company-managed).
- Team-managed projects have their own issue types and fields; IDs differ from company-managed.
fields=*allmakes searches slow; request only what you print or write.- A field missing from the edit screen returns “Field cannot be set”; check
GET /rest/api/3/issue/{key}/editmeta. - Sprint is a custom field holding sprint IDs; moving issues uses the Agile API
(
POST /rest/agile/1.0/sprint/{id}/issue).
Verification
- Re-run the scoping JQL plus the new state (
... AND status = Done) and compare counts. - Open two changed issues’ history (
GET /rest/api/3/issue/{key}/changelog) to confirm author and values. - For rules: Automation -> Audit log shows each execution, matched issues and failed actions.
Report template
**Scope:** JQL `project = OPS AND labels = legacy AND status != Done` (212 issues)
**Change:** transitioned to "Won't do", comment added; notifications on
**Result:** 209 succeeded, 3 failed (OPS-1402, OPS-1517, OPS-1688: transition not available from Blocked)
**Proof:** re-run JQL returns 3; log at out/ops-cleanup-2026-10-28.csv What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Jira Automation"
short_description: "Bulk-edit Jira safely, from JQL to proof"
default_prompt: "$jira-automation Close every OPS ticket labelled legacy that has not been touched in 90 days" references/smart-values.md
# Jira Automation smart values and rule debugging
Read this when a rule uses smart values, web requests, lookups or branches, or when a rule
runs but produces the wrong result.
## Core smart values
| Smart value | Gives |
|---|---|
| `{{issue.key}}`, `{{issue.id}}` | Key (OPS-12) and numeric ID |
| `{{issue.summary}}`, `{{issue.description}}` | Text fields (description renders as wiki-style text) |
| `{{issue.status.name}}`, `{{issue.issueType.name}}` | Names of status and type |
| `{{issue.assignee.accountId}}`, `{{issue.assignee.displayName}}` | Assignee |
| `{{issue.reporter.emailAddress}}` | Only if the actor can see emails |
| `{{issue.fields.customfield_10016}}` or `{{issue.Story Points}}` | Custom fields by ID (stable) or name (breaks on rename) |
| `{{issue.parent.key}}` | Parent (subtask or child of epic) |
| `{{issue.url}}` | Browse link |
| `{{triggerIssue.key}}` | Issue that fired the rule, inside a branch |
| `{{fieldChange.fromString}}`, `{{fieldChange.toString}}` | Old/new value in a Field value changed trigger |
| `{{initiator.accountId}}` | User whose action fired the rule |
| `{{comment.body}}`, `{{comment.author.displayName}}` | In comment triggers |
| `{{webhookData}}` | Parsed JSON body of an incoming webhook |
| `{{webResponse.status}}`, `{{webResponse.body.id}}` | After Send web request with "wait for response" |
| `{{lookupIssues.size}}` | Count after Lookup issues |
| `{{rule.name}}`, `{{rule.actor.displayName}}` | Rule metadata |
## Dates
- `{{now}}`, `{{now.plusDays(7)}}`, `{{now.plusBusinessDays(3)}}`, `{{now.minusHours(2)}}`
- Format: `{{now.format("yyyy-MM-dd")}}`, `{{issue.created.jiraDate}}`, `{{issue.duedate.isoDate}}`
- Compare: `{{issue.duedate.isBefore(now)}}` inside an Advanced compare condition (`equals true`).
- Rules run in the rule's configured time zone; scheduled rules that check "today" drift if the
team spans zones.
## Lists and iteration
```text
{{#issue.labels}}{{.}}{{^last}}, {{/}}{{/}} -> "a, b, c"
{{#lookupIssues}}* {{key}} {{summary}}
{{/}} -> one line per looked-up issue
{{issue.subtasks.size}} -> count of subtasks
{{issue.fixVersions.name.join(", ")}} -> names joined
```
## Text functions
`{{issue.summary.toLowerCase()}}`, `.trim()`, `.substring(0, 50)`, `.replace("x","y")`,
`.match("([A-Z]+-\d+)")`, `.isEmpty()`, `.asNumber`, `.urlEncode`, `.jsonEncode`.
Use `.jsonEncode` on every user-supplied value placed into a JSON web request body; a quote in a
summary otherwise breaks the JSON.
```json
{"text": "{{issue.key}}: {{issue.summary.jsonEncode}}", "url": "{{issue.url}}"}
```
## Math and conditionals
- `{{#=}}{{issue.Story Points}} * 2{{/}}`
- `{{#if(equals(issue.priority.name, "Highest"))}}PAGE{{/}}`
- `{{issue.assignee.displayName|Unassigned}}` gives a default when empty.
## Variables
Create variable action: `{{myVar}}` is always a string. Convert with `.asNumber` before math, and
compare with Advanced compare.
## Debugging a rule
1. Add a "Log action" with the smart values you rely on, run once, read the Audit log.
2. Empty output means the path is wrong or the actor cannot see the field; try the field ID form.
3. In branches, `{{issue}}` is the branch issue; use `{{triggerIssue}}` for the original.
4. Actions inside a branch run in parallel per issue; do not assume order between them.
5. A rule edited by one action and re-triggered by "Issue updated" is a loop; narrow the trigger
or add a condition on `{{initiator}}`.
6. "Re-fetch issue data" action is needed when a previous action changed a field and a later
condition reads it.
## Incoming webhook pattern
- Trigger: Incoming webhook, "Issues provided in the webhook HTTP POST body" or a JQL.
- Caller sends the secret header shown on the trigger page; keep it in the caller's secret store.
- Validate input with an Advanced compare on `{{webhookData.type}}` before any create/edit.
- Return value: incoming webhooks respond immediately; callers must not wait on rule results.
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.