Testland
Browse all skills & agents

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-workflow
View source

bug-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:

TrackerAPI shapeLifecycle modelDeep dive
Jira CloudREST v3, ADF rich textConfigurable workflow engine; look up transition IDs at runtimeWorked below + references/jira.md
LinearGraphQL onlyPer-team WorkflowState objects; resolve by type, not display namereferences/linear.md
GitHub IssuesREST + Projects v2 GraphQLTwo states (open/closed) + state_reason; severity/priority via labelsreferences/github-issues.md
Azure DevOpsWIT REST 7.1, JSON PatchProcess-template states (Agile: New/Active/Resolved/Closed); WIQL searchreferences/azuredevops.md

The tracker-agnostic rules that hold on all four platforms:

  • Dedupe before create. CI bug filing must not duplicate when the same failure recurs: search open bugs by title/summary first, comment on the existing bug on a hit, and fail closed (skip the create, surface the error) if the search itself errors.
  • Severity and priority are different axes - keep both fields and score them independently (severity-vs-priority-reference).
  • Never hard-code lifecycle identifiers. Jira transition IDs, Linear state names, and ADO state strings are all tenant/team/process-specific - discover them at runtime.
  • Dry-run bulk operations. Bulk transitions are not trivially reversible; log the plan and verify counts before applying.
  • Secrets in env vars / secret stores, never in code.

When to use

  • Filing a bug from a CI test failure (fed by the from-CI-failure workflow in bug-report-template, qa-bug-repro).
  • Bulk-transitioning bugs after a release.
  • Building a triage script that pulls new defects and applies severity / priority based on labels.
  • Backing a duplicate-defect search backend.

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.xml

Where 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:

  • Linear - references/linear.md: GraphQL-only API; issueCreate / issueUpdate mutations; per-team workflowStates resolved by lifecycle type (never display name); priority enum where 1 = Urgent; personal-key vs OAuth Bearer header difference.
  • GitHub Issues - references/github-issues.md: two states only; the canonical-lifecycle-to-labels mapping; state_reason transitions (completed / not_planned / duplicate / reopened); Projects v2 GraphQL and the gh CLI.
  • Azure DevOps - references/azuredevops.md: JSON Patch work-item mutations (application/json-patch+json); WIQL triage / dedupe queries; process-template state names; optimistic concurrency via test /rev; PR / build artifact links; az boards CLI.

Anti-patterns

Anti-patternWhy it failsFix
Hard-coding transition IDs / state namesWorkflow or process-template updates break the runner silentlyDiscover at runtime (Jira GET /transitions, Linear workflowStates by type, ADO process template)
Plain-text description in JiraAPI returns 400 - Jira v3 requires ADFWrap as {"type": "doc", "version": 1, "content": [...]}
No deduplication before createEach retry of a flaky test creates a new bugSearch by summary first; comment on existing
Severity as built-in priorityConflates two axes (severity-vs-priority-reference)Use a custom Severity field or severity-* labels
Storing the API token in codeToken leakUse environment variables / secret stores
Polling metadata endpoints on every callRate-limitedCache per workflow scheme, refresh on 4xx
Bulk transitions without dry-runCannot easily reverse if wrong stateAlways run in dry-run mode first; log all changes

Limitations

  • Workflow is per-project / per-team / per-process. A transition or state name in one project may not exist in another - handle "not found" gracefully on every platform.
  • Custom fields are tenant-specific. Jira field IDs (customfield_10039) and ADO severity availability vary; discover at deploy time.
  • Rich text differs per platform. Jira needs full ADF; Linear takes Markdown; GitHub takes Markdown; ADO takes HTML. Test code-block / table rendering per platform.
  • Rate limits everywhere. Jira per-minute, Linear ~1500 req/15 min, GitHub search 30 req/min unauthenticated, ADO per-user throttling - bulk operations need throttling and retry-with-backoff.
  • Query injection. JQL text ~, WIQL CONTAINS WORDS, and GitHub search all interpolate user text - escape quotes and reserved characters.

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 namePurposeExample value
System.TitleSummary"Login fails on SSO redirect"
System.DescriptionRepro steps (HTML)"<b>Steps:</b><ol>..."
Microsoft.VSTS.Common.PriorityPriority 1-42
Microsoft.VSTS.Common.SeveritySeverity (process-defined)"2 - High"
System.AssignedToTriage assignee"user@company.com"
System.TagsLabels"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-patternWhy it failsFix
Sending Content-Type: application/json on create/updateAPI returns HTTP 415Use 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 updatesOverwrites changes made between read and writeAdd {"op": "test", "path": "/rev", "value": rev} as the first operation
Direct WIQL string interpolation of user inputWIQL injection via reserved chars (', [, ])Strip or escape reserved characters before interpolating
Ignoring $top truncation on WIQL responsesSilently misses items when the queue exceeds the capCheck len(hits) == top; paginate or increase $top (max 20 000)
Building vstfs:/// URIs without project/repo GUIDsArtifact links silently fail or link to the wrong targetFetch projectId and repoId from the Repos API first
Creating a Bug per flaky-test recurrencePollutes the backlogAlways comment on the existing open bug instead of filing a new one

Limitations

  • Process-template state names vary. Agile uses Active; Scrum uses Committed; CMMI uses Active with additional substates. Query the process template for the project before hard-coding state strings.
  • Severity field depends on process template. Agile and CMMI include Microsoft.VSTS.Common.Severity; Scrum does not by default. Use GET /_apis/wit/workitemtypes/Bug/fields to verify field availability.
  • Comments API is preview. The workItems/{id}/comments endpoint is api-version 7.1-preview.3 and its contract may change.
  • Artifact link URIs require project and repo GUIDs. Display names are not accepted; resolve them via GET /_apis/projects and GET /_apis/git/repositories first.
  • Rate limits. Azure DevOps Services enforces per-user and per-IP throttling; bulk operations need retry-with-backoff on HTTP 429.

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:

ConventionExample labels
Severityseverity:critical, severity:high, severity:medium, severity:low, severity:trivial
Prioritypriority:p1, priority:p2, priority:p3, priority:p4, priority:p5
Lifecyclestatus:triage, status:confirmed, status:in-progress, status:in-review, status:verified, status:wontfix, status:duplicate
Defect typetype:regression, type:performance, type:security
Componentcomponent: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:

CanonicalGitHub representation
Newopen + status:triage
Open / Acknowledgedopen + status:confirmed
Assignedopen + status:confirmed + assignees set
In Progressopen + status:in-progress + linked draft PR
Fixedopen + status:in-review + ready PR
Verifiedopen + status:verified
Closed (success)closed + state_reason: completed
Reopenedopen + state_reason: reopened
Deferred / Wontfixclosed + state_reason: not_planned + label status:wontfix
Rejectedclosed + state_reason: not_planned + label not-a-bug
Duplicateclosed + 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.xml

Use the auto-provided GITHUB_TOKEN for in-repo automation; for cross-repo, use a fine-grained PAT.

Anti-patterns

Anti-patternWhy it failsFix
Closing without state_reasonDefaults to completed - wrong for not-a-bug / duplicateAlways set state_reason explicitly
Severity / priority in title prefix"[CRITICAL]" prefixes - not searchable; not filterableUse labels
Free-form status labels per teamCross-team queries breakAdopt the canonical label vocabulary above
Search-rate-limit ignoredBulk dedupe scripts get 403sThrottle to 30 req/min unauth, 5000 authenticated
No X-GitHub-Api-Version headerFuture API changes silently break codeAlways set the version header
Plain-text body (no Markdown)Loses code-block formattingUse Markdown in body

Limitations

  • Open / closed only. Rich lifecycle expressed via labels + Projects requires team discipline; the API doesn't enforce it.
  • No native severity / priority fields. Conventions vary across orgs - the workflow is portable only if the team adopts the label vocabulary above.
  • Projects v2 is GraphQL. REST + GraphQL hybrid; engineers need both.
  • Cross-repo dedupe. GitHub Issues are per-repo; cross-repo duplicate detection needs the Search API with an org: qualifier.

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

  • ADF descriptions. Rich descriptions (code blocks, tables) need full ADF construction - see developer.atlassian.com/cloud/jira/platform/apis/document/structure.
  • JQL injection. text ~ "user input" accepts JQL operators - always escape quotes and reserved characters before interpolating.

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:

IntegerLabel
0No priority
1Urgent
2High
3Medium
4Low

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.xml

file-linear-bug.py reads the JUnit XML, extracts the first failure, deduplicates via find_dupes, and calls create_or_attach.

Anti-patterns

Anti-patternWhy it failsFix
Authorization: Bearer <lin_api_*>Wrong header format for personal keysPersonal keys: header without Bearer; OAuth: header with Bearer
Hard-coded state names ("Done")Team-renamed states break the runnerResolve by type (canonical) not name (display)
Priority 1 = "low"Reversed expectation; 1 = Urgent in LinearDocument the enum; use the constant table
Plain-text in description fieldMarkdown is accepted but Linear renders blocks differently than JiraTest rendering for code blocks / tables
Single workflowStates query for all teamsHigh latency on large workspaces; data overflowFilter by team
No dedupe before createSame flaky test generates many issuesSearch by title contains; comment-attach
Querying state { name } instead of state { type }Filter logic breaks when team renames statesQuery type for stability

Limitations

  • State type enum is small. Five values cover the lifecycle; fine-grained sub-states (e.g., "Code Review" vs "In Progress") share type: started. Use name + type together when needed.
  • Webhook complement. For event-driven workflows (notification on state change), pair with Linear webhooks rather than polling.
  • Rate limits. ~1500 requests / 15 min per token; bulk operations need throttling.

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.