clawskills-mcp 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -185,7 +185,7 @@ const REQUIRED_SECTIONS = [
185
185
  const KNOWN_SKILLS = [
186
186
  "asana", "dynamics365", "figma", "github", "hubspot",
187
187
  "jira", "monday", "salesforce", "servicenow", "zendesk",
188
- "slack", "stripe", "notion",
188
+ "slack", "stripe", "notion", "linear",
189
189
  ];
190
190
  describe("real skills", () => {
191
191
  let skills;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawskills-mcp",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "MCP server exposing ClawSkills API integration skill docs to AI agents",
5
5
  "license": "MIT",
6
6
  "author": "ClawSkills Contributors",
package/skills/INDEX.md CHANGED
@@ -23,6 +23,7 @@ A curated library of integration skill documents for the most common SaaS platfo
23
23
  | Slack | [slack/skill.md](./slack/skill.md) | All teams | Real-time messaging, notifications, event-driven automation via Web API |
24
24
  | Stripe | [stripe/skill.md](./stripe/skill.md) | Engineering, Finance, RevOps | Payments, subscriptions, refunds, webhooks, and Connect for platforms |
25
25
  | Notion | [notion/skill.md](./notion/skill.md) | Product, Engineering, Ops | Docs, databases, and blocks API for knowledge base and project automation |
26
+ | Linear | [linear/skill.md](./linear/skill.md) | Engineering, DevOps | Issue tracking, sprints, and webhooks via GraphQL API |
26
27
 
27
28
  ---
28
29
 
@@ -182,6 +183,20 @@ A curated library of integration skill documents for the most common SaaS platfo
182
183
 
183
184
  ---
184
185
 
186
+ ### Linear → [skill.md](./linear/skill.md)
187
+ 1. Create an issue with title, description, priority, state, assignee, and labels
188
+ 2. Update an issue (state, priority, assignee) using UUID or shorthand ID (ENG-123)
189
+ 3. Query issues with AND/OR filters (state type, priority, assignee, label) and pagination
190
+ 4. Get all workflow states for a team (cache before creating issues)
191
+ 5. Post a comment on an issue
192
+ 6. Create an attachment linking an external resource (GitHub PR, Sentry error, Zendesk ticket)
193
+ 7. Incremental sync — query issues updated since a timestamp using relative duration filters
194
+ 8. Register a webhook via `webhookCreate` mutation
195
+ 9. Verify webhook signature (HMAC-SHA256 `Linear-Signature` header) and dispatch events
196
+ 10. Add an issue to a cycle (sprint); query active/upcoming cycles
197
+
198
+ ---
199
+
185
200
  ### Notion → [skill.md](./notion/skill.md)
186
201
  1. Create a database row (page with properties: title, select, date, people)
187
202
  2. Query a database with AND/OR filters and sorts, paginating all results
@@ -231,6 +246,9 @@ These scenarios span multiple skill docs — reference both tools:
231
246
  | Deal closed → Notion project page | Salesforce / HubSpot | Notion | Closed Won trigger → create onboarding project row in Notion database |
232
247
  | GitHub release → Notion changelog | GitHub | Notion | `release` event → append block to changelog database page |
233
248
  | Alert fired → Notion runbook page | PagerDuty / any webhook | Notion | Incident fires → create structured runbook page with checklist blocks |
249
+ | GitHub PR merged → Linear issue done | GitHub | Linear | PR `merged` event → `issueUpdate` state to Completed |
250
+ | Sentry error → Linear bug | Sentry | Linear | New issue alert → `issueCreate` with error title, stack trace, priority |
251
+ | Linear issue done → Zendesk ticket resolved | Linear | Zendesk | Webhook `state.type=completed` → update ticket status |
234
252
  | Subscription cancelled → support ticket | Stripe | Zendesk / Jira | `customer.subscription.deleted` → create ticket for churn review |
235
253
  | Invoice paid → project provisioned | Stripe | Asana / Monday.com | `invoice.paid` webhook → create onboarding project or board item |
236
254
 
@@ -253,6 +271,7 @@ These scenarios span multiple skill docs — reference both tools:
253
271
  | Slack | Bot token (OAuth 2.0 app install) | `xoxb-...` Bearer token | Per-method scopes: `chat:write`, `channels:read`, `users:read`, etc. |
254
272
  | Stripe | Secret API key or Restricted key | `Authorization: Bearer sk_live_...` | Per-resource scopes on restricted keys (None / Read / Write) |
255
273
  | Notion | Internal Integration Secret or OAuth token | `Authorization: Bearer secret_...` | Per-capability: Read/Insert/Update content, Read users |
274
+ | Linear | Personal API key or OAuth 2.0 | API key: `Authorization: <KEY>` (no Bearer) · OAuth: `Authorization: Bearer <TOKEN>` | `read`, `write`, `issues:create`, `comments:create`, `admin` |
256
275
 
257
276
  ---
258
277
 
@@ -273,10 +292,11 @@ These scenarios span multiple skill docs — reference both tools:
273
292
  | Slack | Tier 1: 1/min · Tier 2: 20/min · Tier 3: 50/min · Tier 4: 100/min · `chat.postMessage`: 1/sec/channel sub-limit | Per method per workspace | HTTP 429 + `Retry-After` header (seconds) |
274
293
  | Stripe | Live: **100 req/sec** · Sandbox: **25 req/sec** · Search: 20/sec | Per account | HTTP 429 + `Stripe-Rate-Limited-Reason` header |
275
294
  | Notion | **3 req/sec average** (burst allowed) | Per workspace | HTTP 429 + `Retry-After` header (seconds) |
295
+ | Linear | API key: **5,000 req/hr** + 250k complexity pts/hr · OAuth: 5,000 req/hr + 2M pts/hr | Per user | HTTP 400 + `RATELIMITED` error code · `X-RateLimit-*` headers |
276
296
 
277
297
  ---
278
298
 
279
- *Last updated: 2026-03-02 (Notion skill added) | See [ROADMAP.md](./ROADMAP.md) for versioning and governance details.*
299
+ *Last updated: 2026-03-02 (Linear skill added) | See [ROADMAP.md](./ROADMAP.md) for versioning and governance details.*
280
300
 
281
301
  ### API version reference (as of 2026-02-22)
282
302
 
@@ -295,3 +315,4 @@ These scenarios span multiple skill docs — reference both tools:
295
315
  | Slack | Web API (no versioned path) | Always check `ok` field (HTTP 200 even on errors); `files.upload` deprecated — use `files.getUploadURLExternal` |
296
316
  | Stripe | `2026-02-25.clover` | Pin with `Stripe-Version` header; amounts in smallest currency unit (cents); never log raw card data |
297
317
  | Notion | `2025-09-03` | `Notion-Version` header required on every request; share pages with integration before API access works |
318
+ | Linear | GraphQL (no versioned path) | Single endpoint `https://api.linear.app/graphql`; OAuth apps created after Oct 2025 require refresh token rotation; priority: 0=None 1=Urgent 2=High 3=Medium 4=Low |
@@ -0,0 +1,800 @@
1
+ # Linear Skill
2
+
3
+ > **Last validated:** 2026-03-02 | **API:** GraphQL (no versioned URL path)
4
+ > **GraphQL endpoint:** `https://api.linear.app/graphql`
5
+ > **Assumed product:** Linear (cloud). The same API powers all Linear workspaces.
6
+ > **⚠️ OAuth note:** Apps created after October 1, 2025 issue short-lived access tokens (24 hr) and require refresh token rotation. Apps created before that date use long-lived tokens until the April 1, 2026 migration deadline.
7
+
8
+ ---
9
+
10
+ ## What this skill enables
11
+
12
+ - Create, update, and triage issues programmatically — from CI pipelines, alerts, CRMs, or support tools.
13
+ - Query issues with rich filters (state, assignee, priority, label, project, cycle) and paginate large result sets.
14
+ - Move issues through workflow states to automate sprint boards and status syncs.
15
+ - Manage projects and cycles: create sprints, assign issues, track progress.
16
+ - Post comments on issues from external systems (Slack alerts, deploy scripts, monitoring tools).
17
+ - Subscribe to real-time changes via webhooks: react when issues are created, updated, or completed.
18
+ - Build two-way syncs: push GitHub PRs, Sentry errors, or Zendesk tickets into Linear and reflect status changes back.
19
+
20
+ ---
21
+
22
+ ## Best-fit use cases
23
+
24
+ | Use case | Why it matters | Primary objects | Typical trigger | Success criteria |
25
+ |----------|---------------|-----------------|-----------------|-----------------|
26
+ | Auto-create issue from error alert | Zero-manual triage for Sentry/PagerDuty alerts | Issue, Team | Error monitoring webhook | Issue created in correct team with priority, description, and error link |
27
+ | Sync GitHub PR → Linear issue status | Keep engineering board accurate without manual updates | Issue, WorkflowState | GitHub PR opened/merged webhook | Issue moves to "In Review" on PR open, "Done" on merge |
28
+ | CI failure → Linear bug | Surface broken builds as actionable issues | Issue, Label, Team | GitHub Actions / CI webhook | Bug created with branch, build URL, error log in description |
29
+ | Zendesk ticket → Linear issue | Escalate customer bugs to engineering | Issue, Comment, Attachment | Zendesk trigger on tag/priority | Linear issue created with Zendesk ticket URL; comment posted on status change |
30
+ | Sprint planning automation | Populate next cycle with backlog issues | Cycle, Issue | Scheduled job or project milestone | Issues added to new cycle; priority and assignee set |
31
+ | Weekly engineering digest | Summarise completed/open issues per team | Issue, WorkflowState | Cron job | Structured report of issues completed/started/blocked this week |
32
+ | SLA breach escalation | Auto-raise priority of issues open too long | Issue | Scheduled job | Issues open > N days escalated to Urgent; assignee notified |
33
+ | Cross-tool status sync | Reflect Linear issue state in Notion/Jira | Issue, WorkflowState | Webhook `update` event on state change | External system updated within seconds of Linear state change |
34
+ | Onboarding tasks | Create a personal issue list for new team members | Issue, Team, User | HRIS onboarding event | Set of issues assigned to new hire in their team |
35
+ | Release tracking | Create a project per release, link issues | Project, Issue | Deployment pipeline | Project created; relevant issues linked; progress visible in Linear |
36
+
37
+ ---
38
+
39
+ ## Key concepts & data model
40
+
41
+ ### Core objects
42
+
43
+ | Object | Description | ID formats |
44
+ |--------|-------------|------------|
45
+ | **Issue** | A unit of work; the central object in Linear | UUID (`9cfb482a-...`) **or** shorthand (`ENG-123`) |
46
+ | **Team** | A group of people; issues always belong to a team | UUID; `key` field holds the prefix (e.g. `"ENG"`) |
47
+ | **WorkflowState** | A status step in a team's workflow (Backlog → Done) | UUID |
48
+ | **User** | A workspace member | UUID |
49
+ | **Project** | A milestone or initiative grouping multiple issues | UUID |
50
+ | **Cycle** | A time-boxed sprint; contains a set of issues | UUID |
51
+ | **Label** | A tag applied to issues for categorisation | UUID |
52
+ | **Comment** | A comment thread entry on an issue | UUID |
53
+ | **Attachment** | A link attached to an issue (PR, ticket, etc.) | UUID |
54
+ | **Webhook** | An outbound event subscription | UUID |
55
+
56
+ ### Issue fields reference
57
+
58
+ | Field | Type | Notes |
59
+ |-------|------|-------|
60
+ | `id` | UUID string | Permanent identifier |
61
+ | `identifier` | string | Shorthand, e.g. `"ENG-42"` — accepts in mutations too |
62
+ | `title` | string | Required on create |
63
+ | `description` | string | Markdown supported |
64
+ | `priority` | integer | 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low |
65
+ | `state` | WorkflowState | Current workflow status |
66
+ | `assignee` | User | `null` if unassigned |
67
+ | `labels` | Label[] | Array of tags |
68
+ | `project` | Project | Parent project, if any |
69
+ | `cycle` | Cycle | Active cycle, if any |
70
+ | `team` | Team | Required; every issue belongs to exactly one team |
71
+ | `createdAt` | ISO 8601 datetime | Set by Linear |
72
+ | `updatedAt` | ISO 8601 datetime | Set by Linear |
73
+ | `completedAt` | ISO 8601 datetime | Non-null when state type is `completed` |
74
+ | `canceledAt` | ISO 8601 datetime | Non-null when state type is `canceled` |
75
+ | `dueDate` | ISO 8601 date string | Optional target date |
76
+ | `estimate` | float | Story points / estimate value |
77
+ | `url` | string | Deep link to the issue in Linear |
78
+
79
+ ### WorkflowState types
80
+
81
+ | `type` | Meaning |
82
+ |--------|---------|
83
+ | `triage` | Initial review queue (if triage enabled) |
84
+ | `backlog` | Not yet scheduled |
85
+ | `unstarted` | Scheduled but not started |
86
+ | `started` | Actively being worked on |
87
+ | `completed` | Done — terminal success state |
88
+ | `canceled` | Cancelled — terminal failure state |
89
+
90
+ ### GraphQL response envelope
91
+
92
+ Every request returns:
93
+
94
+ ```json
95
+ {
96
+ "data": { ... },
97
+ "errors": [ { "message": "...", "extensions": { "code": "..." } } ]
98
+ }
99
+ ```
100
+
101
+ A `200 OK` can still contain errors in the `errors` array — always check it. `data` and `errors` can coexist (partial success).
102
+
103
+ ---
104
+
105
+ ## Authentication & permissions
106
+
107
+ ### Personal API key (server-to-server, simplest)
108
+
109
+ Create in **Linear → Settings → Security & privacy → API keys**.
110
+
111
+ ```bash
112
+ curl -X POST https://api.linear.app/graphql \
113
+ -H "Content-Type: application/json" \
114
+ -H "Authorization: YOUR_API_KEY" \
115
+ -d '{"query": "{ viewer { id name email } }"}'
116
+ ```
117
+
118
+ Note: API keys use the header **without** the `Bearer` prefix. The key inherits the permissions of the user who created it.
119
+
120
+ ### OAuth 2.0 (multi-workspace apps)
121
+
122
+ ```
123
+ 1. Redirect user →
124
+ https://linear.app/oauth/authorize
125
+ ?client_id=<CLIENT_ID>
126
+ &redirect_uri=<CALLBACK_URL>
127
+ &response_type=code
128
+ &scope=read,issues:create,comments:create
129
+
130
+ 2. Exchange code →
131
+ POST https://api.linear.app/oauth/token
132
+ Content-Type: application/x-www-form-urlencoded
133
+ Body: code=...&client_id=...&client_secret=...
134
+ &redirect_uri=...&grant_type=authorization_code
135
+
136
+ 3. Use access token →
137
+ Authorization: Bearer <ACCESS_TOKEN>
138
+ ```
139
+
140
+ **Scopes:**
141
+
142
+ | Scope | Access |
143
+ |-------|--------|
144
+ | `read` | Read all workspace data (default) |
145
+ | `write` | Modify all workspace data |
146
+ | `issues:create` | Create issues and attachments only |
147
+ | `comments:create` | Post comments only |
148
+ | `timeSchedule:write` | Manage time schedules |
149
+ | `admin` | Full admin-level access (use sparingly) |
150
+
151
+ **Token lifetimes (post-October 2025 apps):**
152
+
153
+ - Access token: **24 hours** (`expires_in: 86399`)
154
+ - Refresh token: long-lived; use to obtain a new access token before expiry
155
+
156
+ ```bash
157
+ # Refresh access token
158
+ curl -X POST https://api.linear.app/oauth/token \
159
+ -H "Content-Type: application/x-www-form-urlencoded" \
160
+ -d "grant_type=refresh_token&refresh_token=<REFRESH_TOKEN>&client_id=<CLIENT_ID>&client_secret=<CLIENT_SECRET>"
161
+ ```
162
+
163
+ > **Migration note:** Apps created before October 1, 2025 use 10-year tokens. These must migrate to refresh token rotation by **April 1, 2026**.
164
+
165
+ ---
166
+
167
+ ## Common workflows (recipes)
168
+
169
+ ### 1. Create an issue
170
+
171
+ ```graphql
172
+ mutation IssueCreate {
173
+ issueCreate(input: {
174
+ title: "Login page throws 500 on Safari"
175
+ description: "## Steps to reproduce\n1. Open Safari\n2. Navigate to /login\n\n**Error:** Internal Server Error\n\n[Sentry link](https://sentry.io/...)"
176
+ teamId: "9cfb482a-81e3-4154-b5b9-2c805e70a02d"
177
+ priority: 2
178
+ stateId: "state-uuid-for-backlog"
179
+ assigneeId: "user-uuid"
180
+ labelIds: ["label-uuid-bug"]
181
+ projectId: "project-uuid"
182
+ }) {
183
+ success
184
+ issue {
185
+ id
186
+ identifier
187
+ url
188
+ }
189
+ }
190
+ }
191
+ ```
192
+
193
+ ```python
194
+ import requests
195
+
196
+ def linear_query(query: str, variables: dict = None) -> dict:
197
+ resp = requests.post(
198
+ "https://api.linear.app/graphql",
199
+ headers={
200
+ "Authorization": API_KEY,
201
+ "Content-Type": "application/json",
202
+ },
203
+ json={"query": query, "variables": variables or {}},
204
+ )
205
+ resp.raise_for_status()
206
+ data = resp.json()
207
+ if data.get("errors"):
208
+ raise RuntimeError(data["errors"])
209
+ return data["data"]
210
+ ```
211
+
212
+ ---
213
+
214
+ ### 2. Update an issue (state, priority, assignee)
215
+
216
+ You can use either the UUID or the shorthand identifier (`ENG-123`) in mutations:
217
+
218
+ ```graphql
219
+ mutation IssueUpdate {
220
+ issueUpdate(
221
+ id: "ENG-123"
222
+ input: {
223
+ stateId: "state-uuid-in-review"
224
+ priority: 1
225
+ assigneeId: "user-uuid"
226
+ dueDate: "2026-03-15"
227
+ }
228
+ ) {
229
+ success
230
+ issue {
231
+ id
232
+ identifier
233
+ state { name }
234
+ priority
235
+ }
236
+ }
237
+ }
238
+ ```
239
+
240
+ To update labels, pass the **full desired array** (not a delta — Linear replaces the label list):
241
+
242
+ ```graphql
243
+ input: { labelIds: ["label-uuid-bug", "label-uuid-urgent"] }
244
+ ```
245
+
246
+ ---
247
+
248
+ ### 3. Query issues with filters and pagination
249
+
250
+ ```graphql
251
+ query IssueList($teamId: String!, $cursor: String) {
252
+ issues(
253
+ first: 50
254
+ after: $cursor
255
+ filter: {
256
+ team: { id: { eq: $teamId } }
257
+ state: { type: { in: ["started", "unstarted"] } }
258
+ priority: { lte: 2 }
259
+ }
260
+ orderBy: updatedAt
261
+ ) {
262
+ pageInfo {
263
+ hasNextPage
264
+ endCursor
265
+ }
266
+ nodes {
267
+ id
268
+ identifier
269
+ title
270
+ priority
271
+ state { name type }
272
+ assignee { name email }
273
+ createdAt
274
+ updatedAt
275
+ }
276
+ }
277
+ }
278
+ ```
279
+
280
+ ```python
281
+ def get_all_issues(team_id: str) -> list:
282
+ query = """...""" # as above
283
+ issues, cursor = [], None
284
+ while True:
285
+ data = linear_query(query, {"teamId": team_id, "cursor": cursor})
286
+ page = data["issues"]
287
+ issues.extend(page["nodes"])
288
+ if not page["pageInfo"]["hasNextPage"]:
289
+ break
290
+ cursor = page["pageInfo"]["endCursor"]
291
+ return issues
292
+ ```
293
+
294
+ ---
295
+
296
+ ### 4. Get workflow states for a team
297
+
298
+ Before creating or updating issues you need state UUIDs. Fetch them once and cache:
299
+
300
+ ```graphql
301
+ query TeamStates($teamId: String!) {
302
+ team(id: $teamId) {
303
+ states {
304
+ nodes {
305
+ id
306
+ name
307
+ type
308
+ position
309
+ }
310
+ }
311
+ }
312
+ }
313
+ ```
314
+
315
+ Map `type` to find the right state: use `"backlog"` for new issues, `"started"` for in-progress, `"completed"` for done.
316
+
317
+ ---
318
+
319
+ ### 5. Post a comment on an issue
320
+
321
+ ```graphql
322
+ mutation CommentCreate {
323
+ commentCreate(input: {
324
+ issueId: "ENG-123"
325
+ body: "Deployed fix in PR #456. Monitoring for 30 minutes before closing."
326
+ }) {
327
+ success
328
+ comment {
329
+ id
330
+ createdAt
331
+ }
332
+ }
333
+ }
334
+ ```
335
+
336
+ ---
337
+
338
+ ### 6. Create an attachment (link an external resource)
339
+
340
+ Link a Sentry issue, GitHub PR, or Zendesk ticket to a Linear issue:
341
+
342
+ ```graphql
343
+ mutation AttachmentCreate {
344
+ attachmentCreate(input: {
345
+ issueId: "ENG-123"
346
+ title: "Sentry: NullPointerException in AuthService"
347
+ url: "https://sentry.io/organizations/acme/issues/12345/"
348
+ iconUrl: "https://sentry.io/favicon.ico"
349
+ subtitle: "10 occurrences · 5 users affected"
350
+ }) {
351
+ success
352
+ attachment { id }
353
+ }
354
+ }
355
+ ```
356
+
357
+ ---
358
+
359
+ ### 7. Incremental sync — issues updated since last run
360
+
361
+ ```graphql
362
+ query UpdatedIssues($since: DateTimeOrDuration!) {
363
+ issues(
364
+ filter: { updatedAt: { gt: $since } }
365
+ orderBy: updatedAt
366
+ first: 250
367
+ includeArchived: true
368
+ ) {
369
+ pageInfo { hasNextPage endCursor }
370
+ nodes {
371
+ id
372
+ identifier
373
+ title
374
+ state { name type }
375
+ updatedAt
376
+ archivedAt
377
+ }
378
+ }
379
+ }
380
+ ```
381
+
382
+ Pass an ISO 8601 timestamp or a relative duration string (`"PT1H"` = last hour, `"P1D"` = last day):
383
+
384
+ ```python
385
+ from datetime import datetime, timezone
386
+
387
+ since = datetime.now(timezone.utc).replace(minute=0, second=0).isoformat()
388
+ data = linear_query(QUERY, {"since": since})
389
+ ```
390
+
391
+ ---
392
+
393
+ ### 8. Register a webhook
394
+
395
+ ```graphql
396
+ mutation WebhookCreate {
397
+ webhookCreate(input: {
398
+ url: "https://your-server.com/webhooks/linear"
399
+ teamId: "9cfb482a-81e3-4154-b5b9-2c805e70a02d"
400
+ resourceTypes: ["Issue", "Comment", "IssueLabel"]
401
+ label: "My Integration"
402
+ secret: "your-signing-secret"
403
+ }) {
404
+ success
405
+ webhook {
406
+ id
407
+ url
408
+ enabled
409
+ }
410
+ }
411
+ }
412
+ ```
413
+
414
+ ---
415
+
416
+ ### 9. Webhook receiver — verify and dispatch
417
+
418
+ ```python
419
+ import hashlib, hmac, time
420
+ from flask import Flask, request, jsonify
421
+
422
+ app = Flask(__name__)
423
+ WEBHOOK_SECRET = "your-signing-secret"
424
+
425
+ @app.post("/webhooks/linear")
426
+ def linear_webhook():
427
+ sig = request.headers.get("Linear-Signature", "")
428
+ body = request.data
429
+
430
+ # Verify HMAC-SHA256 signature
431
+ expected = hmac.new(
432
+ WEBHOOK_SECRET.encode(),
433
+ body,
434
+ hashlib.sha256,
435
+ ).hexdigest()
436
+ if not hmac.compare_digest(sig, expected):
437
+ return jsonify(error="invalid signature"), 400
438
+
439
+ payload = request.json
440
+
441
+ # Replay attack prevention — reject events > 5 minutes old
442
+ ts = payload.get("webhookTimestamp", 0) / 1000 # ms → s
443
+ if abs(time.time() - ts) > 300:
444
+ return jsonify(error="stale event"), 400
445
+
446
+ action = payload["action"] # "create" | "update" | "remove"
447
+ event_type = payload["type"] # "Issue" | "Comment" | etc.
448
+ data = payload["data"]
449
+ updated_from = payload.get("updatedFrom", {}) # previous values on "update"
450
+
451
+ match (event_type, action):
452
+ case ("Issue", "create"):
453
+ handle_issue_created(data)
454
+ case ("Issue", "update"):
455
+ handle_issue_updated(data, updated_from)
456
+ case ("Comment", "create"):
457
+ handle_comment_created(data)
458
+
459
+ return jsonify(ok=True), 200
460
+ ```
461
+
462
+ **Key rules:**
463
+ - Always verify `Linear-Signature` before processing.
464
+ - Use `updatedFrom` on `"update"` events to detect what specifically changed (e.g. `"stateId"` in `updatedFrom` means the state changed).
465
+ - Return `200` quickly — do heavy work in a background job.
466
+ - Make handlers **idempotent** — Linear retries up to 3 times.
467
+
468
+ ---
469
+
470
+ ### 10. Add an issue to a cycle (sprint)
471
+
472
+ ```graphql
473
+ mutation IssueUpdate {
474
+ issueUpdate(
475
+ id: "ENG-123"
476
+ input: { cycleId: "cycle-uuid" }
477
+ ) {
478
+ success
479
+ issue { id cycle { number name } }
480
+ }
481
+ }
482
+ ```
483
+
484
+ To get current/upcoming cycles for a team:
485
+
486
+ ```graphql
487
+ query TeamCycles($teamId: String!) {
488
+ team(id: $teamId) {
489
+ cycles(
490
+ filter: { completedAt: { null: true } }
491
+ orderBy: createdAt
492
+ ) {
493
+ nodes {
494
+ id
495
+ number
496
+ name
497
+ startsAt
498
+ endsAt
499
+ }
500
+ }
501
+ }
502
+ }
503
+ ```
504
+
505
+ ---
506
+
507
+ ## Query patterns & filtering
508
+
509
+ ### Filter operators
510
+
511
+ | Operator | Types | Example |
512
+ |----------|-------|---------|
513
+ | `eq` / `neq` | any | `priority: { eq: 1 }` |
514
+ | `in` / `nin` | any | `state: { type: { in: ["started", "unstarted"] } }` |
515
+ | `lt` / `lte` / `gt` / `gte` | number, date | `priority: { lte: 2 }` |
516
+ | `contains` / `notContains` | string | `title: { contains: "login" }` |
517
+ | `containsIgnoreCase` | string | `title: { containsIgnoreCase: "ERROR" }` |
518
+ | `startsWith` / `endsWith` | string | `title: { startsWith: "[BUG]" }` |
519
+ | `null` | optional fields | `assignee: { null: true }` (unassigned) |
520
+
521
+ ### AND / OR logic
522
+
523
+ Filters are AND by default. Use the `or` key for OR conditions:
524
+
525
+ ```graphql
526
+ filter: {
527
+ or: [
528
+ { priority: { eq: 1 } }
529
+ { priority: { eq: 2 } }
530
+ ]
531
+ }
532
+ ```
533
+
534
+ Mix AND and OR:
535
+
536
+ ```graphql
537
+ filter: {
538
+ team: { id: { eq: "..." } }
539
+ or: [
540
+ { assignee: { email: { eq: "alice@example.com" } } }
541
+ { assignee: { email: { eq: "bob@example.com" } } }
542
+ ]
543
+ }
544
+ ```
545
+
546
+ ### Relation filters
547
+
548
+ Filter on related object properties directly:
549
+
550
+ ```graphql
551
+ # Issues assigned to a specific user by email
552
+ filter: { assignee: { email: { eq: "alice@example.com" } } }
553
+
554
+ # Issues with a specific label
555
+ filter: { labels: { name: { eq: "bug" } } }
556
+
557
+ # Issues in a specific project
558
+ filter: { project: { name: { containsIgnoreCase: "Q1" } } }
559
+ ```
560
+
561
+ ### Relative date filters
562
+
563
+ Use ISO 8601 durations for dynamic date windows:
564
+
565
+ ```graphql
566
+ # Issues updated in the last 2 weeks
567
+ filter: { updatedAt: { gt: "P2W" } }
568
+
569
+ # Issues due in the next 7 days
570
+ filter: { dueDate: { lt: "P7D" } }
571
+ ```
572
+
573
+ ### Pagination parameters
574
+
575
+ | Parameter | Default | Max | Notes |
576
+ |-----------|---------|-----|-------|
577
+ | `first` | 50 | 250 | Items per page (forward pagination) |
578
+ | `after` | — | — | `endCursor` from previous `pageInfo` |
579
+ | `last` | — | 250 | Items per page (backward pagination) |
580
+ | `before` | — | — | `startCursor` from previous `pageInfo` |
581
+ | `includeArchived` | `false` | — | Set `true` to include archived/deleted items |
582
+ | `orderBy` | `createdAt` | — | `createdAt` or `updatedAt` |
583
+
584
+ ### Selecting only what you need
585
+
586
+ Linear uses complexity-based rate limiting — requesting fewer fields costs fewer complexity points. Avoid requesting deeply nested fields (e.g. `issue.project.lead.assignedIssues`) unless necessary. Use the `X-Complexity` response header to monitor cost.
587
+
588
+ ---
589
+
590
+ ## Reliability: rate limits, retries, idempotency
591
+
592
+ ### Rate limits
593
+
594
+ | Auth type | Requests / hr | Complexity points / hr | Max per query |
595
+ |-----------|-------------|----------------------|---------------|
596
+ | API key | 5,000 | 250,000 | 10,000 pts |
597
+ | OAuth app | 5,000 | 2,000,000 | 10,000 pts |
598
+ | Unauthenticated | 60 | 10,000 | 10,000 pts |
599
+
600
+ Requests are tracked **per user** — all API keys for the same user share the quota.
601
+
602
+ ### Rate limit response headers
603
+
604
+ | Header | Description |
605
+ |--------|-------------|
606
+ | `X-RateLimit-Requests-Limit` | Total allowed requests in window |
607
+ | `X-RateLimit-Requests-Remaining` | Remaining requests |
608
+ | `X-RateLimit-Requests-Reset` | Unix timestamp when window resets |
609
+ | `X-RateLimit-Complexity-Limit` | Total allowed complexity points |
610
+ | `X-RateLimit-Complexity-Remaining` | Remaining complexity points |
611
+ | `X-Complexity` | Complexity cost of the current query |
612
+
613
+ ### Rate limit error
614
+
615
+ HTTP `400` with `RATELIMITED` in `extensions.code`:
616
+
617
+ ```json
618
+ {
619
+ "errors": [{
620
+ "message": "Too many requests, please try again later",
621
+ "extensions": {
622
+ "code": "RATELIMITED",
623
+ "type": "RATE_LIMITED"
624
+ }
625
+ }]
626
+ }
627
+ ```
628
+
629
+ ### Retry with exponential backoff
630
+
631
+ ```python
632
+ import time, random, requests
633
+
634
+ def linear_query_with_retry(query: str, variables: dict = None, max_retries: int = 5) -> dict:
635
+ for attempt in range(max_retries):
636
+ resp = requests.post(
637
+ "https://api.linear.app/graphql",
638
+ headers={"Authorization": API_KEY, "Content-Type": "application/json"},
639
+ json={"query": query, "variables": variables or {}},
640
+ )
641
+ data = resp.json()
642
+
643
+ # Check for rate limit error (returned as HTTP 400)
644
+ errors = data.get("errors", [])
645
+ if any(e.get("extensions", {}).get("code") == "RATELIMITED" for e in errors):
646
+ if attempt == max_retries - 1:
647
+ raise RuntimeError("Rate limit exceeded after retries")
648
+ wait = (2 ** attempt) + random.uniform(0, 1)
649
+ time.sleep(wait)
650
+ continue
651
+
652
+ if resp.status_code >= 500:
653
+ time.sleep(2 ** attempt)
654
+ continue
655
+
656
+ resp.raise_for_status()
657
+ if errors:
658
+ raise RuntimeError(errors)
659
+ return data["data"]
660
+ ```
661
+
662
+ ### Idempotency
663
+
664
+ Linear GraphQL mutations are **not idempotent** — creating an issue twice creates two issues. To avoid duplicates:
665
+ - Store Linear issue IDs alongside your source records (e.g., in Sentry, store the Linear issue UUID in a custom field).
666
+ - Before creating, query for an existing issue by attachment URL or external ID:
667
+
668
+ ```graphql
669
+ query FindByAttachment($url: String!) {
670
+ issueSearch(query: $url) {
671
+ nodes { id identifier url }
672
+ }
673
+ }
674
+ ```
675
+
676
+ Or filter by a known title pattern + team to detect duplicates before inserting.
677
+
678
+ ---
679
+
680
+ ## Error handling & troubleshooting
681
+
682
+ ### GraphQL error structure
683
+
684
+ ```json
685
+ {
686
+ "errors": [
687
+ {
688
+ "message": "Entity not found: Issue - could not find Issue (id: ENG-999)",
689
+ "extensions": {
690
+ "code": "ENTITY_NOT_FOUND",
691
+ "type": "ENTITY_NOT_FOUND",
692
+ "userPresentableMessage": "Issue not found"
693
+ },
694
+ "path": ["issue"]
695
+ }
696
+ ]
697
+ }
698
+ ```
699
+
700
+ ### Error codes → action
701
+
702
+ | `extensions.code` | HTTP | Meaning | Fix |
703
+ |-------------------|------|---------|-----|
704
+ | `UNAUTHENTICATED` | 401 | Missing or invalid token | Check `Authorization` header format; API key = no Bearer prefix, OAuth = Bearer prefix |
705
+ | `FORBIDDEN` | 403 | Token lacks permission for this operation | Add required scope; check user's Linear permissions |
706
+ | `ENTITY_NOT_FOUND` | 200 | Object doesn't exist or isn't accessible | Verify ID; check the object belongs to the workspace |
707
+ | `RATELIMITED` | 400 | Rate limit exceeded | Retry with exponential backoff; reduce query complexity |
708
+ | `INPUT_ERROR` / `VALIDATION_ERROR` | 400 | Invalid input field | Check `message` for which field; verify UUIDs exist |
709
+ | `INTERNAL_SERVER_ERROR` | 500 | Linear server error | Retry with backoff; check https://linearstatus.com |
710
+ | `NETWORK_ERROR` | — | Connection failure | Retry with backoff |
711
+
712
+ ### Common issues
713
+
714
+ | Symptom | Likely cause | Fix |
715
+ |---------|-------------|-----|
716
+ | `UNAUTHENTICATED` with valid key | Wrong header format | API key: `Authorization: <KEY>` (no Bearer). OAuth: `Authorization: Bearer <TOKEN>` |
717
+ | `ENTITY_NOT_FOUND` on valid issue ID | Issue belongs to a different workspace | Verify the API key / token matches the workspace |
718
+ | Mutation returns `success: false` | Invalid state/user/label UUID | Fetch valid IDs first; UUIDs are workspace-specific |
719
+ | Empty `nodes` in query | No matching results or wrong filter | Test without filter first; verify team ID |
720
+ | `RATELIMITED` on API key | Personal key shares user's 5k req/hr quota | Switch to OAuth app (2M complexity pts/hr); reduce request frequency |
721
+ | Webhook not firing | Webhook disabled after repeated failures | Re-enable in Linear settings; check your endpoint returns 2xx |
722
+ | `updatedFrom` missing on webhook | Not an update event | `updatedFrom` only present when `action: "update"` |
723
+ | Refresh token rejected (OAuth) | Token not rotated within expiry window | Store and refresh before expiry; check migration status if pre-Oct 2025 app |
724
+
725
+ ---
726
+
727
+ ## Security & compliance
728
+
729
+ ### Token storage
730
+
731
+ - Store API keys and OAuth tokens in environment variables or a secrets manager — never in source code.
732
+ - API keys have no expiry — rotate them in Linear settings if compromised.
733
+ - OAuth access tokens expire in 24 hours (post-Oct 2025 apps). Store both access and refresh tokens securely; never log them.
734
+
735
+ ### Least-privilege scopes
736
+
737
+ Use the narrowest OAuth scope for your use case:
738
+
739
+ | Integration type | Recommended scopes |
740
+ |-----------------|-------------------|
741
+ | Read-only reporting | `read` |
742
+ | Issue creation from alerts | `read,issues:create` |
743
+ | Posting comments | `read,comments:create` |
744
+ | Full automation (create, update, transition) | `read,write` |
745
+ | Admin tooling | `read,write,admin` |
746
+
747
+ Avoid `write` when `issues:create` suffices — it limits blast radius on a compromised token.
748
+
749
+ ### Webhook security
750
+
751
+ - Always verify `Linear-Signature` using HMAC-SHA256 before processing any payload.
752
+ - Validate `webhookTimestamp` is within 5 minutes to prevent replay attacks.
753
+ - Rotate webhook secrets in Linear settings if exposed.
754
+
755
+ ### Data considerations
756
+
757
+ - Linear issues may contain PII (names, emails in `assignee`, comments). Ensure data passing through pipelines is handled per your privacy policy.
758
+ - Linear is SOC 2 Type II compliant. Verify your integration's data residency requirements against Linear's data processing agreement.
759
+
760
+ ---
761
+
762
+ ## Testing checklist
763
+
764
+ ### Setup
765
+
766
+ 1. Create a test workspace (or test team in your workspace).
767
+ 2. Generate a personal API key: **Settings → Security & privacy → API keys**.
768
+ 3. Create a test team, note its UUID.
769
+ 4. Use the Apollo Studio explorer at `https://studio.apollographql.com/public/Linear-API` to explore and test queries interactively.
770
+
771
+ ### QA checklist
772
+
773
+ - [ ] `{ viewer { id name email } }` returns the authenticated user → token is valid
774
+ - [ ] `teams { nodes { id name key } }` lists all accessible teams
775
+ - [ ] `issueCreate` with title + teamId → `success: true`, issue `identifier` returned (e.g. `ENG-1`)
776
+ - [ ] `issueUpdate` with shorthand ID (`ENG-1`) → `success: true`, field updated
777
+ - [ ] `issues(filter: { state: { type: { eq: "started" } } })` returns only in-progress issues
778
+ - [ ] Pagination: `first: 1` returns `hasNextPage: true`; `after: endCursor` retrieves next page
779
+ - [ ] `includeArchived: true` includes archived issues that are hidden by default
780
+ - [ ] Relative date filter `updatedAt: { gt: "P1D" }` returns only recently-modified issues
781
+ - [ ] `commentCreate` posts a comment; visible in Linear UI
782
+ - [ ] `webhookCreate` → endpoint receives test payload; `Linear-Signature` verifies correctly
783
+ - [ ] Webhook `updatedFrom` present on issue update; `action: "create"` has no `updatedFrom`
784
+ - [ ] Rate limit: burst >5,000 requests/hr with same user → `RATELIMITED` error returned; retry succeeds
785
+ - [ ] Invalid UUID in mutation → `ENTITY_NOT_FOUND` or `INPUT_ERROR`; error handled gracefully
786
+ - [ ] OAuth refresh: access token refreshed using refresh token before 24-hour expiry
787
+
788
+ ---
789
+
790
+ ## Sources
791
+
792
+ - [Linear GraphQL API — Getting Started](https://linear.app/developers/graphql)
793
+ - [Authentication (API keys + OAuth 2.0)](https://linear.app/developers/oauth-2-0-authentication)
794
+ - [OAuth Scopes](https://linear.app/developers/oauth-2-0-authentication)
795
+ - [Rate Limiting](https://linear.app/developers/rate-limiting)
796
+ - [Filtering](https://linear.app/developers/filtering)
797
+ - [Webhooks](https://linear.app/developers/webhooks)
798
+ - [GraphQL Schema Reference (Apollo Studio)](https://studio.apollographql.com/public/Linear-API/schema/reference?variant=current)
799
+ - [Linear GitHub — schema.graphql](https://github.com/linear/linear/blob/master/packages/sdk/src/schema.graphql)
800
+ - [API Status](https://linearstatus.com)