Claude
Skills
Sign in
Back

Using Linear

Included with Lifetime
$97 forever

Guide for working with Linear project management via GraphQL API. Use when creating/updating Linear issues, changing status, adding comments, or uploading files/screenshots. Covers raw GraphQL, Python SDK (linear-py), issue search, mutations, and file upload workflows with Google Cloud Storage signed URLs. Tested October 2025.

Backend & APIsscripts

What this skill does


# Using Linear

Comprehensive guide for working with Linear project management system via GraphQL API and Python SDK.

**Last tested**: October 26, 2025
**Test workspace**: cs1060f25
**Recommended approach**: Raw GraphQL with urllib

## When to Use This Skill

Use this skill when:
- Creating or updating Linear issues programmatically
- Searching for issues by identifier or filter
- Changing issue status (e.g., marking as "Done")
- Adding comments to issues
- Uploading files or screenshots to Linear issues
- Automating Linear workflows with Python scripts

## API Approaches

### Raw GraphQL (Recommended) ⭐

**Pros**: Full feature support, file uploads, variables, no dependencies
**Cons**: More verbose
**Use when**: Need file uploads, complex queries, or full API access

```python
import json
import urllib.request

def graphql_request(query, variables=None):
    url = "https://api.linear.app/graphql"
    payload = {"query": query}
    if variables:
        payload["variables"] = variables

    req = urllib.request.Request(
        url,
        data=json.dumps(payload).encode('utf-8'),
        headers={
            "Authorization": "lin_api_...",
            "Content-Type": "application/json"
        }
    )

    with urllib.request.urlopen(req) as response:
        return json.loads(response.read().decode('utf-8'))
```

### Python SDK (linear-py)

**Pros**: Simpler API for basic operations
**Cons**: No file uploads, no variables in queries, limited field support (no `estimate`!)
**Use when**: Only need simple reads

```python
from linear import Linear

client = Linear("lin_api_...")
teams = client.teams()  # Returns list of dicts
```

**SDK Limitations**:
- Uses snake_case (`team_id`) not camelCase (`teamId`)
- Cannot pass variables to GraphQL queries
- No file upload support
- `estimate` parameter doesn't work in create_issue

**Recommendation**: Use raw GraphQL for any real work. SDK only for quick prototypes.

### Comprehensive Guide

See `references/linear-api-comprehensive-guide.md` for:
- Detailed SDK comparison with tested examples
- Advanced GraphQL patterns (pagination, filtering, bulk operations)
- File upload workflow variations
- Common gotchas and solutions
- Testing results from cs1060f25 workspace

## API Authentication

Linear uses API tokens for authentication. Include the token in request headers:

```python
headers = {
    "Authorization": "lin_api_...",
    "Content-Type": "application/json"
}
```

**API Endpoint**: `https://api.linear.app/graphql`

## Common Operations

### 1. Search for Issue by Identifier

To find an issue like "UNIFIED-26":

```python
query = """
query SearchIssues($filter: IssueFilter!) {
  issues(filter: $filter) {
    nodes {
      id
      identifier
      title
    }
  }
}
"""

variables = {
    "filter": {
        "number": {"eq": 26}  # Extract number from "UNIFIED-26"
    }
}

result = graphql_request(query, variables)

# Find exact match
for issue in result["data"]["issues"]["nodes"]:
    if issue["identifier"] == "UNIFIED-26":
        return issue
```

**Important**: Filter by number first, then match exact identifier to handle multiple teams.

### 2. Update Issue Status

To mark an issue as "Done":

```python
mutation = """
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
  issueUpdate(id: $id, input: $input) {
    success
    issue {
      id
      state {
        name
      }
    }
  }
}
"""

variables = {
    "id": issue_id,
    "input": {
        "stateId": "done_state_id"  # Get from team workflow states
    }
}
```

### 3. Update Issue Due Date

To set a due date on an issue:

```python
mutation = """
mutation UpdateIssueDueDate($id: String!, $dueDate: TimelessDate!) {
  issueUpdate(id: $id, input: {dueDate: $dueDate}) {
    success
    issue {
      id
      identifier
      dueDate
    }
  }
}
"""

variables = {
    "id": issue_id,
    "dueDate": "2025-11-03"  # ISO 8601 format: YYYY-MM-DD
}

result = graphql_request(mutation, variables)
```

**TimelessDate Type**:
- Scalar type that accepts ISO 8601 date format: `YYYY-MM-DD`
- Also accepts shortcuts like `"2021"` for midnight Jan 01 2021
- Accepts ISO 8601 duration strings added to current date (e.g., `"-P2W1D"` = 2 weeks and 1 day ago)
- Common format: `"2025-11-03"` for November 3, 2025

### 4. Update Issue Labels

**IMPORTANT:** Linear doesn't have `issueAddLabel` mutation. Use `issueUpdate` with `labelIds` array instead.

To add or update labels on an issue:

```python
# First, get current labels
get_issue_query = """{
  issues(filter: {identifier: {eq: "UNIFIED-15"}}) {
    nodes {
      id
      labels { nodes { id name } }
    }
  }
}"""

issue_result = graphql_request(get_issue_query)
issue = issue_result['data']['issues']['nodes'][0]
issue_id = issue['id']
current_label_ids = [label['id'] for label in issue['labels']['nodes']]

# Add new label to existing ones
mutation = """
mutation UpdateIssueLabels($id: String!, $labelIds: [String!]!) {
  issueUpdate(id: $id, input: {labelIds: $labelIds}) {
    success
    issue {
      id
      labels { nodes { name } }
    }
  }
}
"""

# Append new label ID to existing labels
new_label_ids = current_label_ids + [new_label_id]

variables = {
    "id": issue_id,
    "labelIds": new_label_ids  # Array of ALL label IDs (existing + new)
}

result = graphql_request(mutation, variables)
```

**Key points:**
- `labelIds` parameter **REPLACES** all labels (doesn't append)
- Always include existing label IDs + new ones
- Use `String!` type for label IDs, not `ID!`

### 4. Add Comment to Issue

To add a text comment (supports Markdown):

```python
mutation = """
mutation CreateComment($input: CommentCreateInput!) {
  commentCreate(input: $input) {
    success
    comment {
      id
      body
    }
  }
}
"""

variables = {
    "input": {
        "issueId": issue_id,
        "body": "Comment text with **Markdown** support"
    }
}
```

## File Upload Workflow

Uploading files to Linear requires a two-step process with Google Cloud Storage signed URLs.

### Overview

1. Request upload URL from Linear GraphQL API
2. Upload file to Google Cloud Storage with required headers
3. Use returned asset URL in comments or issue descriptions

### Critical Discovery: Required Headers

**Google Cloud Storage signed URLs require EXACT headers** that match the cryptographic signature. Linear's API provides these headers - they MUST be included in the upload request.

### Step 1: Request Upload URL

Query Linear's `fileUpload` mutation with file metadata:

```python
query = """
mutation FileUpload($size: Int!, $filename: String!, $contentType: String!) {
  fileUpload(size: $size, filename: $filename, contentType: $contentType) {
    success
    uploadFile {
      uploadUrl
      assetUrl
      headers {
        key
        value
      }
    }
  }
}
"""

variables = {
    "size": os.path.getsize(file_path),
    "filename": os.path.basename(file_path),
    "contentType": "image/png"  # or appropriate MIME type
}

result = graphql_request(query, variables)
upload_data = result["data"]["fileUpload"]["uploadFile"]
```

**Response includes**:
- `uploadUrl`: Google Cloud Storage signed URL (valid for 60 seconds)
- `assetUrl`: Final Linear CDN URL for the uploaded file
- `headers`: Array of required headers for upload

### Step 2: Upload File with Required Headers

**Critical**: Include ALL headers returned by Linear API:

```bash
curl -X PUT \
  -H "Content-Type: image/png" \
  -H "x-goog-content-length-range: [exact_size],[exact_size]" \
  -H 'Content-Disposition: attachment; filename="..."' \
  -T /path/to/file \
  "[uploadUrl]"
```

**Headers breakdown**:
1. `Content-Type`: Must match `contentType` from mutation (part of signature)
2. `x-goog-content-length-range`: Exact file size range (provided by Linear)
3. `Content-Disposition`: Filename for download (provided by Linear)

**Python example using urllib**:

```python
import urllib.request

# Build headers from Linear's response
upload_headers = {"Content-Typ

Related in Backend & APIs