Integrations No. 409

Jira and Linear APIs skill for Claude Code

Ticket automation that never files the same bug twice. Claude automates issue trackers with production habits: an external key and mapping table so retries never create duplicate tickets, Jira's current JQL search endpoint with nextPageToken paging, Atlassian Document Format bodies, transitions looked up by ID, Linear GraphQL with complexity-aware queries, and HMAC-verified webhooks that ignore the integration's own writes.

  • nameissue-tracker-apis
  • size227 lines · ≈2,750 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Automates Jira Cloud and Linear from code - Jira REST v3 with API tokens or OAuth 2.0, enhanced JQL search with nextPageToken paging, Atlassian Document Format bodies, transitions and custom fields; Linear GraphQL with the SDK, filters, cursor pagination and complexity limits; signed webhooks from both; and idempotent sync that never files duplicates. Use when creating or updating issues from alerts, CI, support tools or scripts, bulk-editing tickets, querying with JQL or Linear filters, or receiving Jira or Linear webhooks.

Try asking

  • When a PagerDuty incident fires, open one Jira ticket per incident and close it on resolve
  • Our Jira sync broke after Atlassian changed the search API. Migrate it
  • Mirror Linear issues labeled customer into our support tool and keep status in sync

Use it when

  • Creating or updating Jira or Linear issues from alerts, CI, or support tools
  • Bulk editing, triaging, or reporting on tickets with JQL or Linear filters
  • Receiving Jira or Linear webhooks in a backend
  • Automation is creating duplicate tickets or hitting rate limits

Skip it when

How to check the result

Running the create path twice with one external key leaves a single ticket, multi-page JQL and Linear queries return every matching issue, and a tampered webhook body is rejected while a replayed one is processed once.

The common mistake it prevents

Calling the retired Jira search endpoint with startAt paging, or sending Markdown as a v3 description, so syncs fail or tickets render as raw JSON errors.

~/.claude/skills/issue-tracker-apis/SKILL.md

--- name: issue-tracker-apis description: Automates Jira Cloud and Linear from code - Jira REST v3 with API tokens or OAuth 2.0, enhanced JQL search with nextPageToken paging,… license: MIT ---

Done well means: automation creates exactly one ticket per real-world thing (no duplicates on retry), queries page to the end without hitting removed endpoints, rich text renders correctly, rate limits are respected from headers rather than guessed, and inbound webhooks are verified before any side effect.

Ground rules

  • Secrets (JIRA_API_TOKEN, LINEAR_API_KEY, OAuth client secrets, webhook secrets) come from env or a secret manager. Never commit them or echo them into logs or ticket bodies.
  • Use a dedicated service account (or an OAuth app / Linear app actor) for automation so tickets are attributable and access survives staff changes.
  • Address things by stable IDs: Jira accountId, field IDs (customfield_10042), transition IDs looked up at runtime; Linear UUIDs for teams, states, and labels.
  • Every automated create must carry an external key (alert fingerprint, Sentry issue ID, Zendesk ticket ID) that you can search on before creating.

Workflow

- [ ] 1. Pick auth (table) and confirm the base URL / site
- [ ] 2. Discover IDs: projects/teams, issue types, workflow states, custom fields
- [ ] 3. Write the dedupe key strategy (search-before-create or client-supplied ID)
- [ ] 4. Implement reads with full pagination and field selection
- [ ] 5. Implement writes (create, update, transition, comment) with retries on 429/5xx
- [ ] 6. Receive webhooks: verify signature on raw body, dedupe, process async
- [ ] 7. Dry-run against a sandbox project/team, then verify

1. Auth

ContextJira CloudLinear
Script or internal jobBasic auth email:api_token against https://<site>.atlassian.netPersonal API key in Authorization: <key>
Multi-tenant app acting for usersOAuth 2.0 (3LO); call https://api.atlassian.com/ex/jira/<cloudId>/rest/api/3/...OAuth 2.0; Authorization: Bearer <token>; actor=app for app-attributed actions
Runs inside JiraForge app (no stored credentials)n/a

Jira OAuth: after the token exchange, call GET https://api.atlassian.com/oauth/token/accessible-resources to get the cloudId for each site the user granted. Request offline_access for refresh tokens, and store the rotating refresh token each time it changes.

2. Discover IDs (Jira)

J="https://$JIRA_SITE.atlassian.net/rest/api/3"; A="$JIRA_EMAIL:$JIRA_API_TOKEN"
curl -s -u "$A" "$J/field" | jq '.[] | select(.custom) | {id, name}'           # custom field IDs
curl -s -u "$A" "$J/issue/createmeta/ENG/issuetypes" | jq '.issueTypes[] | {id, name}'
curl -s -u "$A" "$J/issue/ENG-123/transitions" | jq '.transitions[] | {id, name, to: .to.name}'
curl -s -u "$A" "$J/user/[email protected]" | jq '.[0].accountId'

Linear equivalents come from GraphQL: teams { nodes { id key name } }, workflowStates(filter: { team: { key: { eq: "ENG" } } }) { nodes { id name type } }, issueLabels { nodes { id name } }.

3. Dedupe strategy

TrackerDefault
JiraStore the external key in a custom field or label (ext-sentry-12345); before creating, search labels = "ext-sentry-12345"; also keep a local mapping table external_key -> issue key
LinearGenerate a deterministic UUID v5 from the external key and pass it as id in issueCreate; a retry with the same ID fails instead of duplicating. Link the source with attachmentCreate (URL-keyed)

Keep the local mapping table as the primary check; search is the fallback for rows created before the table existed. Serialize creates per external key (a lock or unique constraint) so two workers cannot race.

4. Reads

Jira search uses the enhanced JQL endpoint. The old /rest/api/3/search with startAt paging has been retired on Cloud; code still calling it must migrate.

import os, requests

def jira_search(jql: str, fields: list[str]):
    s = requests.Session()
    s.auth = (os.environ["JIRA_EMAIL"], os.environ["JIRA_API_TOKEN"])
    url = f"https://{os.environ['JIRA_SITE']}.atlassian.net/rest/api/3/search/jql"
    token = None
    while True:
        body = {"jql": jql, "fields": fields, "maxResults": 100}
        if token:
            body["nextPageToken"] = token
        r = s.post(url, json=body, timeout=30)
        r.raise_for_status()
        data = r.json()
        yield from data.get("issues", [])
        token = data.get("nextPageToken")
        if not token or data.get("isLast"):
            break
  • Request fields explicitly; the enhanced endpoint returns little more than IDs by default.
  • Totals are not returned in the page; use POST /rest/api/3/search/approximate-count when a count is needed for UI.
  • Always add an ORDER BY (e.g. ORDER BY updated ASC, key ASC) for incremental sync, and query updated >= "<last_sync - 5m>" with an overlap window.

Linear uses Relay cursors; max first is 250 but smaller pages keep complexity down:

import { LinearClient } from "@linear/sdk";
const linear = new LinearClient({ apiKey: process.env.LINEAR_API_KEY });

let issues = await linear.issues({
  first: 100,
  filter: { team: { key: { eq: "ENG" } }, state: { type: { nin: ["completed", "canceled"] } } },
});
const all = [...issues.nodes];
while (issues.pageInfo.hasNextPage) { issues = await issues.fetchNext(); all.push(...issues.nodes); }

SDK model properties like issue.assignee are lazy and each costs a request; for bulk reads send one raw GraphQL query selecting exactly the nested fields you need (linear.client.rawRequest(query, vars)).

For a cheat sheet of JQL clauses and Linear filter shapes (dates, status categories, relative times, empty checks, cycles, sub-issues), read references/query-cheatsheet.md when composing a non-trivial query.

5. Writes

Jira v3 rich text fields (description, comment body, textarea custom fields) require Atlassian Document Format (ADF) JSON, not Markdown or wiki markup:

{
  "fields": {
    "project": { "key": "ENG" },
    "issuetype": { "id": "10004" },
    "summary": "Checkout 5xx spike on api-gateway",
    "labels": ["ext-pagerduty-Q1W2E3", "automated"],
    "assignee": { "id": "5b10ac8d82e05b22cc7d4ef5" },
    "description": {
      "type": "doc", "version": 1,
      "content": [
        { "type": "paragraph", "content": [{ "type": "text", "text": "Error rate above 2% for 10 minutes." }] },
        { "type": "paragraph", "content": [{ "type": "text", "text": "Runbook", "marks": [{ "type": "link", "attrs": { "href": "https://runbooks.example.com/checkout" } }] }] }
      ]
    }
  }
}
  • POST /rest/api/3/issue creates; POST /rest/api/3/issue/bulk takes up to 50.
  • Status changes are transitions, not field edits: look up the transition ID by target status name for that issue, then POST /rest/api/3/issue/{key}/transitions with {"transition":{"id":"31"}}. Transitions can require screen fields (resolution); include them in fields.
  • PUT /rest/api/3/issue/{key}?notifyUsers=false for bulk edits to avoid email storms (needs admin-level permission for the flag).
  • Linear issueCreate takes Markdown in description, priority 0 (none) to 4 (low) with 1 = urgent, and IDs for teamId, stateId, labelIds, assigneeId.
mutation Create($input: IssueCreateInput!) {
  issueCreate(input: $input) { success issue { id identifier url } }
}

Rate limits:

  • Jira returns 429 with Retry-After; honor it plus jitter, cap concurrency at a few requests in flight per site, and back off on 503 the same way.
  • Linear: API keys get 2,500 requests/hour and 3,000,000 complexity points/hour per user; OAuth apps 5,000 requests and 2,000,000 points. A single query over 10,000 points is rejected. Read X-RateLimit-Requests-Remaining and X-Complexity and slow down before zero.

6. Webhooks

Verify over the raw bytes before parsing; respond 200 within a few seconds and process from a queue. Dedupe on the delivery ID.

SourceSignatureReplay/dedupe
LinearLinear-Signature: hex HMAC-SHA256 of raw body with the webhook signing secretReject if body webhookTimestamp (ms) is more than 60 s from now; dedupe on Linear-Delivery
Jira (webhook with a secret configured)X-Hub-Signature: sha256=<hex> HMAC-SHA256 of raw bodyDedupe on issue ID + timestamp + webhookEvent
Jira without a secret option (older registrations)None: put a long random token in the URL path, check it in constant time, and allowlist Atlassian egress IPsSame
import crypto from "node:crypto";
function verifyHex(raw: Buffer, header: string | null, secret: string, prefix = "") {
  const expected = prefix + crypto.createHmac("sha256", secret).update(raw).digest("hex");
  const a = Buffer.from(header ?? ""), b = Buffer.from(expected);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Linear: verifyHex(raw, req.headers["linear-signature"], process.env.LINEAR_WEBHOOK_SECRET)
// Jira:   verifyHex(raw, req.headers["x-hub-signature"], process.env.JIRA_WEBHOOK_SECRET, "sha256=")

Webhooks registered by OAuth or Connect apps through /rest/api/3/webhook expire (about 30 days) and must be refreshed on a schedule. Linear retries failed deliveries a few times over hours and may disable an endpoint that keeps failing; alert on disabled webhooks.

Loop prevention: when your automation updates an issue, the tracker fires a webhook back. Tag your writes (a label, or check the actor is your service account / app) and ignore events your own integration caused.

Gotchas

  • Jira usernames and userKey are gone in Cloud; only accountId works for assignee, reporter, and JQL (assignee = 5b10ac8d... or assignee = currentUser()).
  • Custom field IDs differ between sites. Never hardcode them across environments; discover by name at startup and cache.
  • JQL string values with spaces or reserved words need quotes; user input in JQL needs escaping or you get JQL injection that widens the query.
  • Team-managed and company-managed Jira projects have different field and workflow setup; test on the same project type as production.
  • Linear identifier (ENG-123) changes when an issue moves teams; store the UUID.
  • Linear archived issues are excluded by default; pass includeArchived: true for sync.

Verification

  • Run the create path twice with the same external key in a sandbox project/team: one ticket exists afterwards.
  • Page through a JQL query returning more than 300 issues and assert the count matches approximate-count within tolerance; repeat for a Linear filter with more than one page.
  • Render a created Jira issue in the UI to confirm ADF (links, lists) displays correctly.
  • Post a webhook with a modified body: 401. Replay a captured valid delivery: processed once.
  • Watch rate-limit headers during a bulk run: remaining never reaches zero and no 429 loops.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/query-cheatsheet.md 105 lines · 4.9 KB
# JQL and Linear filter cheat sheet

Read this when composing a query beyond "open issues in project X". Every pattern is shown
for Jira (JQL) and Linear (GraphQL `IssueFilter`) where both exist.

## Scoping

| Intent | JQL | Linear filter |
|---|---|---|
| One project / team | `project = ENG` | `{ team: { key: { eq: "ENG" } } }` |
| Several | `project in (ENG, OPS)` | `{ team: { key: { in: ["ENG", "OPS"] } } }` |
| Exclude | `project != SANDBOX` | `{ team: { key: { neq: "SANDBOX" } } }` |
| One issue type | `issuetype = Bug` | `{ labels: { name: { eq: "Bug" } } }` (Linear has no issue types; teams usually use labels) |

## Status

| Intent | JQL | Linear |
|---|---|---|
| Not done (any workflow) | `statusCategory != Done` | `{ state: { type: { nin: ["completed", "canceled"] } } }` |
| In progress | `statusCategory = "In Progress"` | `{ state: { type: { eq: "started" } } }` |
| Exact status | `status = "In Review"` | `{ state: { name: { eq: "In Review" } } }` |
| Moved to a status recently | `status CHANGED TO "Done" AFTER -7d` | Query issues with `completedAt: { gt: "-P1W" }` |
| Was ever in a status | `status WAS "Blocked"` | Not available; read issue history |

Prefer `statusCategory` (Jira) and `state.type` (Linear) over status names: names differ per
workflow, categories do not. Linear state types: `triage`, `backlog`, `unstarted`, `started`,
`completed`, `canceled`.

## Time

| Intent | JQL | Linear |
|---|---|---|
| Updated in last day | `updated >= -1d` | `{ updatedAt: { gt: "-P1D" } }` (ISO 8601 duration relative to now; `-` means past) |
| Updated since a sync point | `updated >= "2026-10-18 09:00"` | `{ updatedAt: { gte: "2026-10-18T09:00:00Z" } }` |
| Created this week | `created >= startOfWeek()` | `{ createdAt: { gte: "<monday ISO>" } }` |
| Overdue | `due < now() AND statusCategory != Done` | `{ dueDate: { lt: "<today>" }, state: { type: { nin: ["completed","canceled"] } } }` |

JQL date literals use the user's (or service account's) profile time zone. Set the service
account's zone to UTC so `"2026-10-18 09:00"` means what you think.

## People

| Intent | JQL | Linear |
|---|---|---|
| Unassigned | `assignee is EMPTY` | `{ assignee: { null: true } }` |
| Assigned to me | `assignee = currentUser()` | Query `viewer { assignedIssues(filter: {...}) }` |
| Specific user | `assignee = 5b10ac8d82e05b22cc7d4ef5` (accountId) | `{ assignee: { email: { eq: "[email protected]" } } }` |
| Reporter / creator | `reporter = <accountId>` | `{ creator: { id: { eq: "<uuid>" } } }` |

## Labels, fields, text

| Intent | JQL | Linear |
|---|---|---|
| Has label | `labels = "ext-sentry-123"` | `{ labels: { name: { eq: "ext-sentry-123" } } }` |
| Has none of labels | `labels not in (wontfix) OR labels is EMPTY` | `{ labels: { every: { name: { nin: ["wontfix"] } } } }` |
| Custom field equals | `cf[10042] = "Enterprise"` or `"Customer Tier[Select List (single choice)]" = Enterprise` | Custom fields do not exist; use labels or projects |
| Text search | `text ~ "timeout"` (fuzzy, tokenized) | `{ title: { containsIgnoreCase: "timeout" } }`; for bodies, `description` or `comments: { body: { contains: ... } }` |
| Priority | `priority in (Highest, High)` | `{ priority: { lte: 2, neq: 0 } }` (1 urgent, 2 high; 0 means no priority) |

`~` is not substring matching; it stems words and ignores punctuation. For exact external
keys, use labels or a dedicated field and `=`.

## Structure

| Intent | JQL | Linear |
|---|---|---|
| Children of an epic/parent | `parent = ENG-100` | `{ parent: { id: { eq: "<uuid>" } } }` |
| Top-level only | `parent is EMPTY` | `{ parent: { null: true } }` |
| Current sprint / cycle | `sprint in openSprints()` | `{ cycle: { isActive: { eq: true } } }` |
| In a project (Linear) | n/a | `{ project: { name: { eq: "Q4 Billing" } } }` |
| Linked issues | `issue in linkedIssues(ENG-1)` (may need a marketplace app or newer JQL functions) | `relations` field on the issue |

## Combining

- JQL: `AND`, `OR`, `NOT`, parentheses. `ORDER BY updated ASC, key ASC` for stable paging.
- Linear: sibling keys in one object are AND; use `or: [ {...}, {...} ]` and `and: [...]` for
  explicit combinations.

```graphql
query Stale($after: String) {
  issues(
    first: 100, after: $after,
    filter: {
      team: { key: { eq: "ENG" } }
      state: { type: { eq: "started" } }
      updatedAt: { lt: "-P2W" }
      or: [{ assignee: { null: true } }, { priority: { eq: 1 } }]
    }
  ) {
    nodes { id identifier title url updatedAt assignee { email } }
    pageInfo { hasNextPage endCursor }
  }
}
```

```text
project = ENG AND statusCategory = "In Progress" AND updated <= -14d
  AND (assignee is EMPTY OR priority = Highest) ORDER BY updated ASC, key ASC
```

## Escaping user input

- JQL: wrap values in double quotes and escape `"` and `\` inside them. Never splice raw input
  next to operators; reject values containing newlines.
- Linear: pass values as GraphQL variables, never by string-building the query.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc