From qa-defect-management
Author and run Jira Cloud bug workflows via REST API v3 - issue creation, state transitions, JQL search for triage queues, severity/priority field updates. Covers issue creation with ADF description, transition lookup + apply, JQL search for duplicate detection, label-based classification (severity/priority/regression), and CI-driven bug filing from test failures. Use when programmatically managing Jira bug lifecycle states (creates, triages, transitions, closes) - distinct from qa-test-reporting/jira-issue-importer which posts test-result-link issues.
How this skill is triggered — by the user, by Claude, or both
Slash command
/qa-defect-management:jira-bug-workflow-runnerThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Jira's workflow engine maps cleanly to the canonical defect lifecycle
Jira's workflow engine maps cleanly to the canonical defect lifecycle
(bug-lifecycle-reference)
but every project's actual workflow is configurable, so the
runner has to look up transition IDs at runtime rather than
hard-code them.
This skill wraps the Jira Cloud REST API v3 (per developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/) for the four core operations: create, transition, update, and search.
bug-report-from-failure).Verified → Closed after deployment).duplicate-defect-finder
agent's search backend.Per Atlassian docs, Jira Cloud REST API v3 uses HTTP Basic auth with an API token:
export JIRA_BASE="https://your-tenant.atlassian.net"
export JIRA_EMAIL="[email protected]"
export JIRA_TOKEN="<api-token-from-id.atlassian.com>"
export JIRA_AUTH=$(echo -n "$JIRA_EMAIL:$JIRA_TOKEN" | base64)
import requests, base64, os
auth = base64.b64encode(
f"{os.environ['JIRA_EMAIL']}:{os.environ['JIRA_TOKEN']}".encode()
).decode()
HEADERS = {
"Authorization": f"Basic {auth}",
"Accept": "application/json",
"Content-Type": "application/json",
}
BASE = os.environ["JIRA_BASE"]
POST /rest/api/3/issue per the API group docs. The description
must be Atlassian Document Format (ADF), not plain text.
def create_bug(project_key, summary, description_text, severity, priority, labels):
payload = {
"fields": {
"project": {"key": project_key},
"summary": summary,
"description": {
"type": "doc",
"version": 1,
"content": [{
"type": "paragraph",
"content": [{"type": "text", "text": description_text}],
}],
},
"issuetype": {"name": "Bug"},
"priority": {"name": priority}, # e.g. "High"
"labels": labels + [f"severity-{severity}"],
}
}
r = requests.post(f"{BASE}/rest/api/3/issue", json=payload, headers=HEADERS)
r.raise_for_status()
return r.json()["key"]
Note: severity is typically a custom field - most tenants
either define a custom Severity field (customfield_XXXXX) or
use labels (severity-critical). The example above uses labels
for portability; see "Severity custom field" below.
Discover the custom-field ID once per tenant:
curl -u "$JIRA_EMAIL:$JIRA_TOKEN" \
"$JIRA_BASE/rest/api/3/field" \
| jq '.[] | select(.name=="Severity") | {id, name}'
# {"id": "customfield_10039", "name": "Severity"}
Then submit it in the create payload:
"customfield_10039": {"value": severity}, # "Critical" | "High" | ...
Workflow transitions are project-specific. Look up the available transitions then apply by transition ID:
def get_transitions(issue_key):
r = requests.get(f"{BASE}/rest/api/3/issue/{issue_key}/transitions",
headers=HEADERS)
r.raise_for_status()
return r.json()["transitions"]
def transition(issue_key, target_state_name):
transitions = get_transitions(issue_key)
match = next((t for t in transitions if t["name"] == target_state_name), None)
if not match:
raise ValueError(f"No transition named {target_state_name}; "
f"available: {[t['name'] for t in transitions]}")
r = requests.post(
f"{BASE}/rest/api/3/issue/{issue_key}/transitions",
json={"transition": {"id": match["id"]}},
headers=HEADERS,
)
r.raise_for_status()
The POST /rest/api/3/issue/{key}/transitions body shape is
{"transition": {"id": "<id>"}} per the API group docs.
PUT /rest/api/3/issue/{key} for arbitrary field updates:
def update_priority(issue_key, priority_name):
r = requests.put(
f"{BASE}/rest/api/3/issue/{issue_key}",
json={"fields": {"priority": {"name": priority_name}}},
headers=HEADERS,
)
r.raise_for_status()
POST /rest/api/3/search/jql returns issues matching a JQL query.
Useful for duplicate detection and triage queues.
def search_jql(jql, max_results=50):
r = requests.post(
f"{BASE}/rest/api/3/search/jql",
json={"jql": jql, "fields": ["summary", "status", "priority"],
"maxResults": max_results},
headers=HEADERS,
)
r.raise_for_status()
return r.json()["issues"]
# Triage queue:
triage = search_jql(
'project = ENG AND issuetype = Bug AND status = "New" ORDER BY created ASC'
)
# Duplicate-candidate search:
dupes = search_jql(
f'project = ENG AND text ~ "{summary_safe}" AND issuetype = Bug'
)
CI bug filing must not duplicate if the same failure recurs. Pair
with duplicate-defect-finder upstream, but also defensively
search before creating:
def create_or_attach(project, summary, body):
existing = search_jql(
f'project = {project} AND summary ~ "\\"{summary}\\"" '
f'AND statusCategory != Done',
max_results=5,
)
if existing:
# Attach a comment to the existing bug instead of duplicating
key = existing[0]["key"]
add_comment(key, f"Recurred at {timestamp()}: {body[:500]}")
return key
return create_bug(project, summary, body, "Medium", "Medium",
labels=["auto-filed", "ci-failure"])
verified = search_jql(
'project = ENG AND status = Verified AND fixVersion = "2026.05.20"',
max_results=1000,
)
for issue in verified:
transition(issue["key"], "Close Issue")
create_bug returns the new issue key (e.g., ENG-12345). Use it
to construct a permalink for downstream consumers:
url = f"{BASE}/browse/{issue_key}"
Search responses include expand, total, startAt, and
issues (the array). Always check total against maxResults
for pagination.
Auto-file a bug from a test failure:
# .github/workflows/test.yml (excerpt)
- name: Run tests
id: tests
run: pytest --junitxml=results.xml
continue-on-error: true
- name: File Jira bug on failure
if: steps.tests.outcome == 'failure'
env:
JIRA_BASE: ${{ secrets.JIRA_BASE }}
JIRA_EMAIL: ${{ secrets.JIRA_EMAIL }}
JIRA_TOKEN: ${{ secrets.JIRA_TOKEN }}
run: python scripts/file-jira-bug.py results.xml
Where file-jira-bug.py parses the JUnit XML, extracts the
failure, deduplicates, and creates / comments per the helpers
above.
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Hard-coding transition IDs | Workflow updates break the runner silently | Look up transitions per call via GET /transitions |
Plain-text description | API returns 400 - Jira v3 requires ADF | Wrap as {"type": "doc", "version": 1, "content": [...]} |
| No deduplication before create | Each retry of a flaky test creates a new bug | Search by summary first; comment on existing |
Severity as built-in priority | Conflates two axes (severity-vs-priority-reference) | Use custom Severity field or severity-* labels |
| Storing the API token in code | Token leak | Use environment variables / secret stores |
Polling /transitions on every call | Rate-limited | Cache per workflow scheme, refresh on 4xx |
| Bulk transitions without dry-run | Cannot easily reverse if wrong state | Always run in dry-run mode first; log all changes |
customfield_10039)
vary; discover at deploy time.text ~ "user input" accepts JQL operators - always escape quotes and reserved characters.bug-lifecycle-reference,
severity-vs-priority-reference.linear-bug-workflow-runner,
github-issues-bug-workflow.bug-report-from-failure,
duplicate-defect-finder.testrail-integration - different scope (test-result posting; not bug workflow).npx claudepluginhub testland/qa --plugin qa-defect-managementGuides creation, editing, and verification of skills for AI coding agents using test-driven development with subagent scenarios. Use when authoring or debugging skills.