bug-tracker-workflow
Repairs defect bookkeeping that reports the wrong numbers - a weekly summary showing zero in the top severity band, a 'new defects this week' figure counting items already fixed, duplicates that were never merged, or one script moving issues across several boards whose workflows disagree. Files, transitions, dedupes, and searches bugs through one tracker-agnostic workflow across Jira, Linear, GitHub Issues, and Azure DevOps: authenticate, dedupe-search before creating, classify severity and priority, transition lifecycle states, and wire idempotent CI-driven filing from test failures. Jira Cloud REST API v3 is worked in full in the body (ADF descriptions, runtime transition lookup, JQL triage and duplicate queries, dry-run bulk transitions). Use when tracker data, defect metrics, or cross-board transitions are wrong or need automating.
Install with skills.sh (any agent)
npx skills add testland/qa --skill bug-tracker-workflowbug-tracker-workflow
Overview
Every mainstream tracker exposes the same four core operations - create, transition, search, and update/comment - behind a different API shape. This skill runs the bug workflow tracker-agnostically, with Jira Cloud REST API v3 worked in full below and per-platform deep dives in references:
| Tracker | API shape | Lifecycle model | Deep dive |
|---|---|---|---|
| Jira Cloud | REST v3, ADF rich text | Configurable workflow engine; look up transition IDs at runtime | Worked below + references/jira.md |
| Linear | GraphQL only | Per-team WorkflowState objects; resolve by type, not display name | references/linear.md |
| GitHub Issues | REST + Projects v2 GraphQL | Two states (open/closed) + state_reason; severity/priority via labels | references/github-issues.md |
| Azure DevOps | WIT REST 7.1, JSON Patch | Process-template states (Agile: New/Active/Resolved/Closed); WIQL search | references/azuredevops.md |
The tracker-agnostic rules that hold on all four platforms:
When to use
Worked primary - Jira Cloud REST API v3
Jira's workflow engine maps cleanly to the canonical defect lifecycle (see the lifecycle reference in severity-vs-priority-reference), but every project's actual workflow is configurable, so the runner looks up transition IDs at runtime rather than hard-coding them. All calls per developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/ (opens in new window).
Authentication
Jira Cloud REST API v3 uses HTTP Basic auth with an API token:
export JIRA_BASE="https://your-tenant.atlassian.net"
export JIRA_EMAIL="you@company.com"
export JIRA_TOKEN="<api-token-from-id.atlassian.com>"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"]Create a bug
POST /rest/api/3/issue. 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 uses labels for portability; discovering and submitting the custom field is in references/jira.md.
Look up and apply a transition
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.
Search via JQL
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'
)Idempotent bug creation
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"])Verify: the statusCategory != Done search must run and return 0 open matches before create_bug fires. If it returns a hit, comment on that key instead of creating; if the search itself errors, fail closed (skip the create and surface the error) rather than filing a possible duplicate.
Bulk transition after release
Dry-run first: a mis-scoped JQL can push hundreds of issues into the wrong state, and a transition is not trivially reversible. Gate the apply behind a flag:
DRY_RUN = True # flip to False only after reviewing the logged plan
verified = search_jql(
'project = ENG AND status = Verified AND fixVersion = "2026.05.20"',
max_results=1000,
)
for issue in verified:
if DRY_RUN:
print(f"[dry-run] {issue['key']}: Verified -> Close Issue")
continue
transition(issue["key"], "Close Issue")Verify: assert the dry-run count and keys match the issue set you intended to close before flipping DRY_RUN to False; if they do not, fix the JQL and re-run the dry run. transition already raises ValueError when the named transition is absent for an issue's workflow, so a workflow mismatch fails loud rather than silently skipping.
CI integration
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.xmlWhere file-jira-bug.py parses the JUnit XML, extracts the failure, deduplicates, and creates / comments per the helpers above.
Verify: assert the create call returned HTTP 2xx and a non-empty issue key before the step reports success. On 400, the description was likely plain text instead of ADF or a required field is missing - fix the payload and re-run. On 429 (rate limit), back off and retry rather than failing the build. If it still fails, leave the test result red so the filing gap stays visible instead of being swallowed.
Other trackers
The same workflow shape on the other three platforms, each with its own auth, create, transition, search, worked example, anti-patterns, and CI wiring:
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Hard-coding transition IDs / state names | Workflow or process-template updates break the runner silently | Discover at runtime (Jira GET /transitions, Linear workflowStates by type, ADO process template) |
Plain-text description in Jira | 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 a custom Severity field or severity-* labels |
| Storing the API token in code | Token leak | Use environment variables / secret stores |
| Polling metadata endpoints 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 |
Limitations
References
Azure DevOps bug workflow
View source (opens in new window)Azure DevOps bug workflow
Deep dive for bug-tracker-workflow. Azure DevOps Boards models defects as Bug work items with a fixed set of built-in states. The canonical state sequence for the Agile process template is New -> Active -> Resolved -> Closed, per learn.microsoft.com/en-us/azure/devops/boards/work-items/guidance/agile-process-workflow (opens in new window). All mutations use the Work Item Tracking REST API (api-version 7.1) with a JSON Patch body, per learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/create (opens in new window).
Authentication
The API supports Personal Access Tokens (PAT) via HTTP Basic auth, where the username is empty and the password is the PAT. Scope required: vso.work_write (grants read, create, and update of work items), per the Work Items - Create security docs (opens in new window).
export ADO_ORG="https://dev.azure.com/my-org"
export ADO_PROJECT="MyProject"
export ADO_PAT="<personal-access-token>"import requests, base64, os
pat = os.environ["ADO_PAT"]
token = base64.b64encode(f":{pat}".encode()).decode()
HEADERS = {
"Authorization": f"Basic {token}",
"Content-Type": "application/json-patch+json",
"Accept": "application/json",
}
BASE = os.environ["ADO_ORG"]
PROJECT = os.environ["ADO_PROJECT"]Note: the Content-Type for all write operations is application/json-patch+json, not application/json. Sending application/json returns HTTP 415.
Create a Bug
POST {org}/{project}/_apis/wit/workitems/$Bug?api-version=7.1. The body is a JSON Patch document (array of operations).
Core Bug fields per the Agile process template:
| Field reference name | Purpose | Example value |
|---|---|---|
System.Title | Summary | "Login fails on SSO redirect" |
System.Description | Repro steps (HTML) | "<b>Steps:</b><ol>..." |
Microsoft.VSTS.Common.Priority | Priority 1-4 | 2 |
Microsoft.VSTS.Common.Severity | Severity (process-defined) | "2 - High" |
System.AssignedTo | Triage assignee | "user@company.com" |
System.Tags | Labels | "ci-failure; regression" |
def create_bug(title, description_html, priority, severity, tags=""):
body = [
{"op": "add", "path": "/fields/System.Title", "value": title},
{"op": "add", "path": "/fields/System.Description",
"value": description_html},
{"op": "add", "path": "/fields/Microsoft.VSTS.Common.Priority",
"value": priority},
{"op": "add", "path": "/fields/Microsoft.VSTS.Common.Severity",
"value": severity},
{"op": "add", "path": "/fields/System.Tags", "value": tags},
]
r = requests.post(
f"{BASE}/{PROJECT}/_apis/wit/workitems/$Bug?api-version=7.1",
json=body, headers=HEADERS,
)
r.raise_for_status()
item = r.json()
return item["id"], item["url"]The response id field is the work item integer ID. Construct the browser URL as {BASE}/{PROJECT}/_workitems/edit/{id}.
Transition state (PATCH)
State transitions use PATCH {org}/{project}/_apis/wit/workitems/{id}?api-version=7.1 with a replace or add operation on /fields/System.State, per learn.microsoft.com/en-us/rest/api/azure/devops/wit/work-items/update (opens in new window).
Unlike Jira, ADO has no separate "transitions" endpoint. Set the target state string directly. Valid values for the Agile Bug type are New, Active, Resolved, and Closed. The optional test op on /rev provides optimistic concurrency: the server rejects the patch if the revision no longer matches, preventing lost updates.
def set_state(work_item_id, target_state, current_rev=None):
body = []
if current_rev is not None:
body.append({"op": "test", "path": "/rev", "value": current_rev})
body.append(
{"op": "add", "path": "/fields/System.State", "value": target_state}
)
r = requests.patch(
f"{BASE}/{PROJECT}/_apis/wit/workitems/{work_item_id}?api-version=7.1",
json=body, headers=HEADERS,
)
r.raise_for_status()
return r.json()["fields"]["System.State"]To also record who resolved and why:
body += [
{"op": "add", "path": "/fields/Microsoft.VSTS.Common.ResolvedReason",
"value": "Fixed"},
{"op": "add", "path": "/fields/System.History",
"value": "Resolved in PR #1234"},
]Search via WIQL
POST {org}/{project}/_apis/wit/wiql?api-version=7.1 runs a Work Item Query Language expression, per learn.microsoft.com/en-us/rest/api/azure/devops/wit/wiql/query-by-wiql (opens in new window). The request body is {"query": "<WIQL string>"}. The response workItems array contains {id, url} objects; batch-fetch field values with a second call (below).
def wiql_search(query, top=50):
r = requests.post(
f"{BASE}/{PROJECT}/_apis/wit/wiql?$top={top}&api-version=7.1",
json={"query": query},
headers={**HEADERS, "Content-Type": "application/json"},
)
r.raise_for_status()
return r.json().get("workItems", []) # [{id, url}, ...]
# Triage queue: all open Bugs ordered by priority then created date
triage_items = wiql_search(
"SELECT [System.Id] FROM WorkItems "
"WHERE [System.WorkItemType] = 'Bug' "
"AND [System.State] NOT IN ('Resolved', 'Closed') "
"ORDER BY [Microsoft.VSTS.Common.Priority] ASC, "
"[System.CreatedDate] DESC"
)
# Duplicate candidate search
dupes = wiql_search(
f"SELECT [System.Id] FROM WorkItems "
f"WHERE [System.WorkItemType] = 'Bug' "
f"AND [System.Title] CONTAINS WORDS 'SSO redirect' "
f"AND [System.State] <> 'Closed'"
)WIQL CONTAINS WORDS is a full-text operator. Do not substitute user input directly; sanitise by stripping WIQL reserved characters ([, ], ') before interpolating into the query string.
Fetch field values after a WIQL query
The WIQL response only returns IDs. Batch-fetch fields with a second call:
def get_work_items(ids, fields=None):
if not ids:
return []
fields_param = ",".join(fields) if fields else (
"System.Id,System.Title,System.State,"
"Microsoft.VSTS.Common.Priority,Microsoft.VSTS.Common.Severity"
)
ids_param = ",".join(str(i["id"]) for i in ids)
r = requests.get(
f"{BASE}/_apis/wit/workitems"
f"?ids={ids_param}&fields={fields_param}&api-version=7.1",
headers={**HEADERS, "Content-Type": "application/json"},
)
r.raise_for_status()
return r.json()["value"]get_work_items returns the full field map per item under value[].fields.
Idempotent bug creation from CI
Search before creating to prevent duplicate defects:
def create_or_comment(title, body_html, priority="2", severity="2 - High"):
hits = wiql_search(
f"SELECT [System.Id] FROM WorkItems "
f"WHERE [System.WorkItemType] = 'Bug' "
f"AND [System.Title] CONTAINS WORDS '{title[:60]}' "
f"AND [System.State] <> 'Closed'",
top=5,
)
if hits:
existing_id = hits[0]["id"]
add_comment(existing_id, f"Recurred: {body_html[:500]}")
return existing_id, False # (id, created)
item_id, _ = create_bug(title, body_html, priority, severity,
tags="ci-failure; auto-filed")
return item_id, True
def add_comment(work_item_id, text_html):
r = requests.post(
f"{BASE}/{PROJECT}/_apis/wit/workItems/{work_item_id}"
f"/comments?api-version=7.1-preview.3",
json={"text": text_html},
headers={**HEADERS, "Content-Type": "application/json"},
)
r.raise_for_status()The comments endpoint is api-version 7.1-preview.3 and its contract may change.
Linking to PRs and builds
Work item relations are attached via a PATCH operation with op: add on /relations/- (see the "Add a link" example in the update API docs). The relation value object contains rel (link type name) and url (target URL).
Link another work item as related:
def link_related(source_id, target_id, comment=""):
target_url = f"{BASE}/_apis/wit/workItems/{target_id}"
body = [{
"op": "add",
"path": "/relations/-",
"value": {
"rel": "System.LinkTypes.Related",
"url": target_url,
"attributes": {"comment": comment},
}
}]
r = requests.patch(
f"{BASE}/{PROJECT}/_apis/wit/workitems/{source_id}?api-version=7.1",
json=body, headers=HEADERS,
)
r.raise_for_status()Link a pull request or build (artifact link). The url for artifact links uses the vstfs:/// URI scheme. For a Git pull request the relation type is ArtifactLink, per learn.microsoft.com/en-us/azure/devops/boards/queries/link-type-reference (opens in new window):
def link_pull_request(work_item_id, org_name, project_id, repo_id, pr_id):
# vstfs artifact URI format for a Git PR:
# vstfs:///Git/PullRequestId/{projectId}/{repoId}/{prId}
artifact_url = (
f"vstfs:///Git/PullRequestId/{project_id}/{repo_id}/{pr_id}"
)
body = [{
"op": "add",
"path": "/relations/-",
"value": {
"rel": "ArtifactLink",
"url": artifact_url,
"attributes": {
"name": "Pull Request",
"comment": f"Fixing PR !{pr_id}",
},
}
}]
r = requests.patch(
f"{BASE}/{PROJECT}/_apis/wit/workitems/{work_item_id}?api-version=7.1",
json=body, headers=HEADERS,
)
r.raise_for_status()Use az boards work-item relation list-type to enumerate all supported link type names for the current organisation, per learn.microsoft.com/en-us/azure/devops/boards/backlogs/add-link (opens in new window).
Bulk close after release
resolved = wiql_search(
"SELECT [System.Id] FROM WorkItems "
"WHERE [System.WorkItemType] = 'Bug' "
"AND [System.State] = 'Resolved' "
"AND [System.IterationPath] UNDER 'MyProject\\\\Sprint 42'"
)
for item in resolved:
set_state(item["id"], "Closed")az boards CLI
The az boards CLI (part of the azure-devops Azure CLI extension) wraps the same REST API. Install with az extension add --name azure-devops.
# Create a Bug
az boards work-item create \
--type Bug \
--title "Login fails on SSO redirect" \
--priority 2 \
--org "$ADO_ORG" \
--project "$ADO_PROJECT"
# Update state
az boards work-item update --id 4210 --state Active \
--org "$ADO_ORG" --project "$ADO_PROJECT"
# Link two work items
az boards work-item relation add --id 4210 \
--relation-type Related --target-id 4205 \
--org "$ADO_ORG"
# WIQL query (returns JSON)
az boards query \
--wiql "SELECT [System.Id],[System.Title] FROM WorkItems \
WHERE [System.WorkItemType]='Bug' AND [System.State]='New'" \
--org "$ADO_ORG" --project "$ADO_PROJECT"Parsing results
create_bug returns (id, url). Build the browser permalink:
permalink = f"{BASE}/{PROJECT}/_workitems/edit/{work_item_id}"wiql_search returns [{id, url}, ...]. Always check the list length before accessing index 0, and compare against the $top cap to detect truncation.
CI integration
# azure-pipelines.yml (excerpt)
- task: PythonScript@0
displayName: "File ADO bug on test failure"
condition: failed()
inputs:
scriptSource: filePath
scriptPath: scripts/file-ado-bug.py
env:
ADO_ORG: $(System.CollectionUri)
ADO_PROJECT: $(System.TeamProject)
ADO_PAT: $(ADO_PAT_SECRET)
BUILD_ID: $(Build.BuildId)
BUILD_URL: $(System.CollectionUri)$(System.TeamProject)/_build/results?buildId=$(Build.BuildId)file-ado-bug.py reads the JUnit XML produced by the test runner, extracts the first failure, deduplicates against open Bugs, and calls create_or_comment.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Sending Content-Type: application/json on create/update | API returns HTTP 415 | Use application/json-patch+json for all PATCH/POST to workitems |
Hard-coding state strings like "In Progress" | State names are process-template-specific (Agile vs Scrum vs CMMI) | Verify state names for the target project's process template before automating |
No test /rev op on concurrent updates | Overwrites changes made between read and write | Add {"op": "test", "path": "/rev", "value": rev} as the first operation |
| Direct WIQL string interpolation of user input | WIQL injection via reserved chars (', [, ]) | Strip or escape reserved characters before interpolating |
Ignoring $top truncation on WIQL responses | Silently misses items when the queue exceeds the cap | Check len(hits) == top; paginate or increase $top (max 20 000) |
Building vstfs:/// URIs without project/repo GUIDs | Artifact links silently fail or link to the wrong target | Fetch projectId and repoId from the Repos API first |
| Creating a Bug per flaky-test recurrence | Pollutes the backlog | Always comment on the existing open bug instead of filing a new one |
Limitations
References
GitHub Issues bug workflow
View source (opens in new window)GitHub Issues bug workflow
Deep dive for bug-tracker-workflow. GitHub Issues has only two states: open and closed. To express the canonical defect lifecycle, teams supplement Issues with labels (severity, priority, status) and optionally Projects v2 (status columns). All REST calls per docs.github.com/en/rest/issues/issues (opens in new window).
Authentication and API version
export GITHUB_TOKEN="ghp_..." # personal access token, classic or fine-grained
export GITHUB_REPO="owner/repo"import requests, os
HEADERS = {
"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2026-03-10",
}
BASE = f"https://api.github.com/repos/{os.environ['GITHUB_REPO']}"Version-sensitive facts:
Create an issue
POST /repos/{owner}/{repo}/issues:
def create_bug(title, body, severity, priority, labels=None):
payload = {
"title": title,
"body": body,
"labels": (labels or []) + [
"bug",
f"severity:{severity}",
f"priority:{priority}",
],
}
r = requests.post(f"{BASE}/issues", json=payload, headers=HEADERS)
r.raise_for_status()
return r.json()Required parameter is title. Optional: body, assignees, milestone, labels, type.
Label conventions
Since GitHub has no first-class severity / priority field, teams adopt label prefixes:
| Convention | Example labels |
|---|---|
| Severity | severity:critical, severity:high, severity:medium, severity:low, severity:trivial |
| Priority | priority:p1, priority:p2, priority:p3, priority:p4, priority:p5 |
| Lifecycle | status:triage, status:confirmed, status:in-progress, status:in-review, status:verified, status:wontfix, status:duplicate |
| Defect type | type:regression, type:performance, type:security |
| Component | component:auth, component:payments, component:ui |
Adopt them consistently - defect-report review checks that severity + priority labels are both present.
State transitions via PATCH
PATCH /repos/{owner}/{repo}/issues/{issue_number}. The state_reason parameter takes completed | not_planned | reopened | duplicate:
def close(issue_number, reason="completed"):
"""reason: completed | not_planned | duplicate"""
r = requests.patch(
f"{BASE}/issues/{issue_number}",
json={"state": "closed", "state_reason": reason},
headers=HEADERS,
)
r.raise_for_status()
result = r.json()
# verify the destructive transition landed; a stale state means a concurrent edit won
assert result["state"] == "closed" and result["state_reason"] == reason, result
return result
def reopen(issue_number):
r = requests.patch(
f"{BASE}/issues/{issue_number}",
json={"state": "open", "state_reason": "reopened"},
headers=HEADERS,
)
r.raise_for_status()
return r.json()Map canonical lifecycle states via labels + close-reason:
| Canonical | GitHub representation |
|---|---|
| New | open + status:triage |
| Open / Acknowledged | open + status:confirmed |
| Assigned | open + status:confirmed + assignees set |
| In Progress | open + status:in-progress + linked draft PR |
| Fixed | open + status:in-review + ready PR |
| Verified | open + status:verified |
| Closed (success) | closed + state_reason: completed |
| Reopened | open + state_reason: reopened |
| Deferred / Wontfix | closed + state_reason: not_planned + label status:wontfix |
| Rejected | closed + state_reason: not_planned + label not-a-bug |
| Duplicate | closed + state_reason: duplicate + comment Duplicate of #N |
Search and comments
def search_issues(q):
r = requests.get(
"https://api.github.com/search/issues",
params={"q": f"repo:{os.environ['GITHUB_REPO']} {q}"},
headers=HEADERS,
)
r.raise_for_status()
return r.json()["items"]
dupes = search_issues(
f'type:issue is:open label:bug "{title_safe}" in:title,body'
)
def add_comment(issue_number, body):
r = requests.post(
f"{BASE}/issues/{issue_number}/comments",
json={"body": body}, headers=HEADERS)
r.raise_for_status()
return r.json()GitHub search has a 30-request-per-minute unauthenticated / higher authenticated rate limit.
Worked example
File a bug from a CI failure idempotently:
def create_or_attach(title, body):
dupes = search_issues(f'is:open label:bug "{title}" in:title')
if dupes:
add_comment(dupes[0]["number"], f"Recurred: {body[:500]}")
return dupes[0]["number"]
issue = create_bug(title, body, severity="medium", priority="p3")
return issue["number"]Verify: search_issues ranks by relevance and can return near-misses, so before attaching to or bulk-closing a hit, assert its title matches the target; skip and log any that do not rather than commenting on or closing the wrong issue, then re-run the dedupe against the corrected query.
Parsing results
Create response includes number (per-repo), html_url (permalink), node_id (GraphQL ID for Projects v2 cross-ref). Search response includes items (issues + PRs), total_count, and incomplete_results (set to true on partial results due to rate limit).
Projects v2 status updates
For richer state (e.g., a Kanban with custom columns), Projects v2 requires GraphQL - the REST API doesn't reach Projects v2:
PROJECTS_MUTATION = """
mutation MoveItem($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(
input: { projectId: $projectId, itemId: $itemId,
fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }
) { projectV2Item { id } }
}
"""
# Discovery of projectId, itemId, fieldId, optionId via the matching queries.Per docs.github.com/en/issues/planning-and-tracking-with-projects.
gh CLI for scripts
The gh CLI handles auth via the user's stored credentials, so scripted workflows skip token wiring (per cli.github.com/manual/gh_issue):
# Create
gh issue create \
--title "Checkout fails for promo X" \
--body-file failure.md \
--label bug,severity:high,priority:p2
# Close with reason
gh issue close 1234 --reason completed
gh issue close 1234 --reason "not planned"
# Search
gh issue list --search 'is:open label:bug "checkout fails"'CI integration
# .github/workflows/test.yml
- name: Run tests
id: tests
run: pytest --junitxml=results.xml
continue-on-error: true
- name: File issue on test failure
if: steps.tests.outcome == 'failure'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPO: ${{ github.repository }}
run: python scripts/file-github-bug.py results.xmlUse the auto-provided GITHUB_TOKEN for in-repo automation; for cross-repo, use a fine-grained PAT.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Closing without state_reason | Defaults to completed - wrong for not-a-bug / duplicate | Always set state_reason explicitly |
| Severity / priority in title prefix | "[CRITICAL]" prefixes - not searchable; not filterable | Use labels |
| Free-form status labels per team | Cross-team queries break | Adopt the canonical label vocabulary above |
| Search-rate-limit ignored | Bulk dedupe scripts get 403s | Throttle to 30 req/min unauth, 5000 authenticated |
No X-GitHub-Api-Version header | Future API changes silently break code | Always set the version header |
| Plain-text body (no Markdown) | Loses code-block formatting | Use Markdown in body |
Limitations
References
Jira Cloud REST API v3 - field details
View source (opens in new window)Jira Cloud REST API v3 - field details
Deep Jira specifics for bug-tracker-workflow. Auth setup, create, transition, and JQL search stay in SKILL.md; this file holds the custom-field, update, and result-parsing detail.
Severity custom field
severity is usually a custom field, not the built-in priority. Discover the 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" | ...Update fields
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()Parsing results
create_bug returns the new issue key (e.g. ENG-12345). Build a permalink:
url = f"{BASE}/browse/{issue_key}"Search responses include expand, total, startAt, and issues (the array). Always check total against maxResults for pagination.
Field-spec notes
Linear bug workflow
View source (opens in new window)Linear bug workflow
Deep dive for bug-tracker-workflow. Linear's API is GraphQL-only (linear.app/developers/graphql (opens in new window)). Unlike Jira's REST workflow engine, Linear's lifecycle is driven by WorkflowState objects: each team has its own states (Backlog / Todo / In Progress / In Review / Done / Cancelled, plus team-specific additions). To transition a defect, set its stateId to the target state's ID.
Authentication
Two auth modes:
# Personal API key (lin_api_*)
export LINEAR_KEY="lin_api_xxxxxxxxxxxxxxxxxx"
# Or OAuth bearer token
export LINEAR_TOKEN="<oauth-access-token>"HEADERS_KEY = {
"Authorization": os.environ["LINEAR_KEY"], # personal key, no Bearer
"Content-Type": "application/json",
}
HEADERS_OAUTH = {
"Authorization": f"Bearer {os.environ['LINEAR_TOKEN']}",
"Content-Type": "application/json",
}
ENDPOINT = "https://api.linear.app/graphql"Note: personal API keys use the Authorization header without the Bearer prefix; OAuth tokens use Bearer. This is unusual - many GraphQL APIs reject the bareword auth - confirmed in Linear's quickstart. A personal API key also bypasses 2FA - use OAuth bearer for user-impersonating flows.
Create a bug
The issueCreate mutation:
import requests, os
QUERY = """
mutation IssueCreate($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue { id identifier url state { name } }
}
}
"""
def create_bug(team_id, title, description_md, priority, state_id, label_ids=None):
variables = {
"input": {
"teamId": team_id,
"title": title,
"description": description_md, # Markdown supported
"priority": priority, # 0=No, 1=Urgent, 2=High, 3=Med, 4=Low
"stateId": state_id, # initial state (e.g., "Backlog" or "Todo")
"labelIds": label_ids or [],
}
}
r = requests.post(ENDPOINT, json={"query": QUERY, "variables": variables},
headers=HEADERS_KEY)
r.raise_for_status()
data = r.json()
if data.get("errors"):
raise RuntimeError(data["errors"])
return data["data"]["issueCreate"]["issue"]Priority integer values
Per Linear's published priority enum:
| Integer | Label |
|---|---|
| 0 | No priority |
| 1 | Urgent |
| 2 | High |
| 3 | Medium |
| 4 | Low |
Reverse of what some might expect: 1 is highest urgency.
Discover state IDs per team
State IDs are per-team. Look them up via the workflowStates query:
STATES_QUERY = """
query Workflow($teamId: String!) {
workflowStates(filter: { team: { id: { eq: $teamId } } }) {
nodes { id name type }
}
}
"""
def get_states(team_id):
r = requests.post(ENDPOINT,
json={"query": STATES_QUERY, "variables": {"teamId": team_id}},
headers=HEADERS_KEY)
r.raise_for_status()
return r.json()["data"]["workflowStates"]["nodes"]type is one of backlog, unstarted, started, completed, canceled - the canonical lifecycle bucket independent of the state's display name. Resolve by type, never by team-customisable name. The unfiltered all-teams form (query { workflowStates { nodes { id name } } }) exists but has high latency on large workspaces - prefer the per-team filter.
Resolve workflow-state by type
Many automation flows want "transition to whatever the team uses as Done" without hard-coding state names:
def transition_to_completed(issue_id, team_id):
done = next(s for s in get_states(team_id) if s["type"] == "completed")
return transition(issue_id, done["id"])The type enum is stable; the name is team-customisable, so resolving by type survives a team renaming its columns.
Transition (update state)
issueUpdate mutation:
UPDATE_QUERY = """
mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue { id state { name } }
}
}
"""
def transition(issue_id, new_state_id):
variables = {"id": issue_id, "input": {"stateId": new_state_id}}
r = requests.post(ENDPOINT, json={"query": UPDATE_QUERY, "variables": variables},
headers=HEADERS_KEY)
r.raise_for_status()
data = r.json()
return data["data"]["issueUpdate"]["success"]issueUpdate accepts the same input fields as issueCreate (except teamId which is immutable) plus assignee, due date, estimate, etc.
Search
The issues query supports filter expressions:
SEARCH_QUERY = """
query SearchIssues($filter: IssueFilter!) {
issues(filter: $filter, first: 50) {
nodes { id identifier title state { name } priority }
}
}
"""
def find_dupes(team_id, title_text):
r = requests.post(ENDPOINT,
json={"query": SEARCH_QUERY, "variables": {"filter": {
"team": {"id": {"eq": team_id}},
"title": {"contains": title_text},
"state": {"type": {"neq": "completed"}},
}}},
headers=HEADERS_KEY)
r.raise_for_status()
return r.json()["data"]["issues"]["nodes"]Filter operators: eq, neq, contains, startsWith, endsWith, plus comparison for numerics.
Worked example
File a bug from a CI failure idempotently: dedupe by title, comment on the existing issue if found, otherwise create it in the team's unstarted state:
def create_or_attach(team_id, title, description):
dupes = find_dupes(team_id, title)
if dupes:
# Comment on the existing issue rather than duplicate
add_comment(dupes[0]["id"], f"Recurred: {description[:500]}")
return dupes[0]["identifier"]
todo_state = next(s for s in get_states(team_id) if s["type"] == "unstarted")
return create_bug(team_id, title, description, priority=3,
state_id=todo_state["id"])["identifier"]add_comment uses the commentCreate mutation (similar shape).
Parsing results
issueCreate.issue.identifier is the human-readable ID (e.g., ENG-1234); issueCreate.issue.url is the canonical permalink. GraphQL errors surface under a top-level errors array even on an HTTP 200 - check data.get("errors") before reading data["data"].
CI integration
- name: File Linear bug on failure
if: failure()
env:
LINEAR_KEY: ${{ secrets.LINEAR_KEY }}
LINEAR_TEAM_ID: ${{ vars.LINEAR_TEAM_ID }}
run: python scripts/file-linear-bug.py results.xmlfile-linear-bug.py reads the JUnit XML, extracts the first failure, deduplicates via find_dupes, and calls create_or_attach.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Authorization: Bearer <lin_api_*> | Wrong header format for personal keys | Personal keys: header without Bearer; OAuth: header with Bearer |
| Hard-coded state names ("Done") | Team-renamed states break the runner | Resolve by type (canonical) not name (display) |
| Priority 1 = "low" | Reversed expectation; 1 = Urgent in Linear | Document the enum; use the constant table |
Plain-text in description field | Markdown is accepted but Linear renders blocks differently than Jira | Test rendering for code blocks / tables |
| Single workflowStates query for all teams | High latency on large workspaces; data overflow | Filter by team |
| No dedupe before create | Same flaky test generates many issues | Search by title contains; comment-attach |
Querying state { name } instead of state { type } | Filter logic breaks when team renames states | Query type for stability |
Limitations
References
Related skills
confirmation-testing-workflow
Procedure for proving that a claimed defect fix actually reached the build under test and actually works. Covers the merge-base ancestry check that proves the running build contains the fix commit rather than trusting a version label, the priority order for choosing which reproduction to re-run, and the VERIFIED / NOT FIXED / BLOCKED verdict table whose governing rule is that any ambiguous, flaky, or unreproducible result resolves to BLOCKED and is never guessed. Scoped to ISTQB confirmation testing (does this specific fix work?), not regression testing (did the fix break something else?), and not triage or severity assignment. Use when a developer has marked a defect Fixed and someone must decide whether it moves to Verified or back to Reopened.
severity-vs-priority-reference
Pure-reference catalog for defect classification: severity (impact on the system / user) vs priority (urgency of fix) on independent axes - the canonical 5-point severity scale (Critical / High / Medium / Low / Trivial), the 5-point priority scale (Immediate / High / Medium / Low / Deferred), the 5x5 matrix with worked S1/P5 and S5/P1 examples, and IEEE 1044-2009 severity classes; plus the full defect lifecycle (ISTQB-canonical states new / open / assigned / fixed / verified / closed / reopened / deferred / rejected / duplicate, allowed and forbidden transitions, tracker vocabulary maps) and the defect-categorisation taxonomies (IEEE 1044 anomaly classification, ISTQB CTAL-TA root-cause categories, Orthogonal Defect Classification) in references. Use when triaging or classifying a defect, configuring a tracker's severity/priority/state fields, reviewing a bug report's classification, or running root-cause analysis.