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.

  • Integrations
  • $jira-automation
  • 221 lines
  • 3 files
  • ~2,600 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- jira-automation

Installs to ~/.agents/skills/jira-automation/ for every project on this machine.

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.

~/.agents/skills/jira-automation/SKILL.md

--- name: jira-automation description: Automates Jira Cloud through the REST API v3 and Jira Automation rules - JQL search with the /search/jql endpoint and nextPageToken paging, creating… license: MIT ---

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

NeedUseWhy
React to Jira events inside Jira (assign, transition, comment)Automation ruleNo hosting, audit log, runs as a rule actor
One-off or scheduled bulk change, report, migrationREST API scriptVersion control, dry runs, retries
Push events to your serviceAdmin webhook (site) or dynamic webhook (Connect/OAuth app)Near real time
Logic that lives inside Jira UI for many sitesForge appHosted by Atlassian, scoped permissions
Jira Data Center / ServerREST API v2 with a personal access tokenv3 and ADF are Cloud only

Before touching anything

  1. Read AGENTS.md for where credentials live and which HTTP client the repo uses.
  2. Confirm site URL (https://<site>.atlassian.net), Cloud vs Data Center, and the project keys.
  3. 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).
  4. 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-count with {"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"])
OperationEndpointNote
Edit fieldsPUT /rest/api/3/issue/{key} with fields or updateupdate supports add/remove on labels, components
Add commentPOST /rest/api/3/issue/{key}/comment with ADF body
Change statusGET then POST /rest/api/3/issue/{key}/transitionsStatus is not a writable field
Bulk createPOST /rest/api/3/issue/bulkUp to 50 issues per call; check per-item errors
Link issuesPOST /rest/api/3/issueLinkType names from /rest/api/3/issueLinkType
Find a userGET /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.

GoalTriggerConditionAction
Auto-assign bugs by componentIssue createdIssue type = BugAssign issue (by component lead or smart value)
Close stale waiting ticketsScheduled, with JQL status = "Waiting for customer" AND updated <= -14d-Transition to Closed + comment
Parent done when all children doneIssue transitioned (to Done)Branch: parent; JQL condition no open childrenTransition parent
External system creates/updates issuesIncoming webhook{{webhookData}} checksCreate or edit issue
Notify a serviceField value changedAdvanced compareSend 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; only accountId works.
  • 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=*all makes 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.yamlgenerated from this page

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.md84 lines, 4.0 KB
# 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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem