clawskills-mcp 0.3.2 → 0.3.4
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.
- package/README.md +2 -1
- package/dist/index.test.js +1 -1
- package/package.json +1 -1
- package/skills/INDEX.md +22 -1
- package/skills/linear/skill.md +800 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
An MCP (Model Context Protocol) server that exposes [ClawSkills](https://github.com/Shanksg/clawskills) API integration skill docs to AI agents.
|
|
4
4
|
|
|
5
|
-
ClawSkills contains structured Markdown
|
|
5
|
+
ClawSkills currently contains 14 structured Markdown skill docs for SaaS APIs such as Salesforce, HubSpot, GitHub, Jira, Stripe, Notion, Slack, and Linear. Each skill tracks its own API version and `Last validated` date, while `clawskills-mcp` has its own npm version for the server itself.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -107,6 +107,7 @@ Skills are bundled inside the npm package — no `SKILLS_DIR` configuration need
|
|
|
107
107
|
- `github` — GitHub REST API + GraphQL
|
|
108
108
|
- `hubspot` — HubSpot CRM API
|
|
109
109
|
- `jira` — Jira Cloud REST API
|
|
110
|
+
- `linear` — Linear GraphQL API
|
|
110
111
|
- `monday` — Monday.com GraphQL API
|
|
111
112
|
- `notion` — Notion REST API (Pages, Databases, Blocks)
|
|
112
113
|
- `salesforce` — Salesforce REST API
|
package/dist/index.test.js
CHANGED
|
@@ -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
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 (
|
|
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)
|