Author and run Linear bug workflows via the GraphQL API: issue creation, state transitions (workflowState assignment), priority assignment (0 No priority / 1 Urgent / 2 High / 3 Medium / 4 Low), label-based classification, search by team and content. Covers the issueCreate mutation, issueUpdate for state transitions, the workflowStates query for per-team state IDs, and Linear's API-key vs OAuth Bearer auth modes; resolve-by-type, CI wiring, and result parsing live in references/. Use when the target tracker is Linear specifically; for other trackers use jira-bug-workflow-runner (Jira) or github-issues-bug-workflow (GitHub Issues). Files and transitions the issue; reproducing the defect is a separate concern.
76
95%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Linear's API is GraphQL-only. 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.
This skill wraps Linear's GraphQL API (linear.app/developers/graphql) for create / update / transition / search.
bug-report-from-failure).Bearer) or OAuth token (with Bearer) - the header format differs (see Authentication).workflowStates and resolve states by type, not display name (see Discover state IDs).issues filter query, then issueCreate in an unstarted state; issueUpdate to transition as the fix progresses (see Search / Create / Transition).Per Linear API docs, 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.
The issueCreate mutation per
linear.app/developers/graphql:
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 (visible across the GraphQL schema and the dashboard tooltip):
| 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 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 of this query is in
references/linear-graphql-reference.md.)
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.
This reuses find_dupes, get_states, and create_bug above:
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"]
# From a failing pytest run:
ident = create_or_attach(
team_id, "Payment webhook retries drop events",
"Repro: send 3 webhooks in 1s; 2 are lost.",
)
print(f"Bug tracked as {ident}") # e.g. ENG-1234add_comment uses the commentCreate mutation (similar shape;
omitted for brevity). issueCreate.issue.identifier is the human-readable
ID (e.g., ENG-1234); issueCreate.issue.url is the canonical permalink.
To resolve the bug to Done after the fix ships without hard-coding state
names, use the transition_to_completed helper in
references/linear-graphql-reference.md.
| 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.bug-lifecycle-reference,
severity-vs-priority-reference.jira-bug-workflow-runner,
github-issues-bug-workflow.bug-report-from-failure.