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.
70
88%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
Deep dive for bug-tracker-workflow. Linear's API is GraphQL-only
(linear.app/developers/graphql).
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.
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.
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"]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.
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.
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.
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.
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.
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).
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"].
- 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-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 |
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.