
Linear
- 38 installs
- 14 repo stars
- Updated April 20, 2026
- nodnarbnitram/claude-code-extensions
Helps with ai & agent building tasks during AI-assisted development.
About
linear is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- linear
- AI & Agent Building
- AI-coding skill
Linear by the numbers
- 38 all-time installs (skills.sh)
- Ranked #8,450 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/nodnarbnitram/claude-code-extensions --skill linearAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 38 |
|---|---|
| repo stars | ★ 14 |
| Last updated | April 20, 2026 |
| Repository | nodnarbnitram/claude-code-extensions ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Linear Project & Ticket Management
This skill manages Linear through the GraphQL endpoint only. Do not use linearis or any other CLI wrapper. The project scripts in .claude/skills/linear/scripts/ share a single GraphQL client and hit https://api.linear.app/graphql directly.
Prerequisites
Required: API Token
export LINEAR_API_TOKEN='lin_api_xxxxx'Get the token from Linear → Settings → Security & Access → Personal API keys.
Verify Setup
uv run .claude/skills/linear/scripts/read-ticket.py ICE-2041If this returns ticket JSON, auth and GraphQL access are working.
IMPORTANT: Use Scripts First
Prefer the scripts in .claude/skills/linear/scripts/ over ad hoc curl calls. They resolve names to IDs, normalize errors, and keep output JSON-shaped for agent workflows. If you need raw queries for debugging or unsupported operations, use references/graphql-reference.md.
When to Use This Skill
- Create or update tickets for feature work, bugs, or remediation
- Create projects and milestones to organize work
- Add comments with progress or review notes
- Create, list, or read project documents
- Move issues into projects or milestones
Scripts Overview
| Script | Purpose |
|---|---|
list-issues.py | List issues with optional team, status, and project filters |
search-issues.py | Full-text search issues |
create-ticket.py | Create a ticket |
read-ticket.py | Read ticket details by identifier or UUID |
update-ticket.py | Update ticket fields |
add-comment.py | Add a comment to a ticket |
create-project.py | Create a project |
add-issues-to-project.py | Add tickets to a project |
create-milestone.py | Create a project milestone |
add-issues-to-milestone.py | Add tickets to a milestone |
create-document.py | Create a project document |
list-documents.py | List documents, optionally by project |
read-document.py | Read a document by UUID |
---
Ticket Operations
List Issues
uv run .claude/skills/linear/scripts/list-issues.py --team ICE-T
uv run .claude/skills/linear/scripts/list-issues.py --team ICE-T --limit 100
uv run .claude/skills/linear/scripts/list-issues.py --team ICE-T --status "Todo,In Progress"
uv run .claude/skills/linear/scripts/list-issues.py --team ICE-T --project "Orca Security Remediation"Options:
--teamfilter by team key or name--limit,-lmax issues to fetch, default50--status,-scomma-separated workflow states--projectproject name or UUID
Search Issues
uv run .claude/skills/linear/scripts/search-issues.py "Orca Security"
uv run .claude/skills/linear/scripts/search-issues.py "CVE" --team ICE-T
uv run .claude/skills/linear/scripts/search-issues.py "Privileged Role" --status "Todo,Triage"
uv run .claude/skills/linear/scripts/search-issues.py "Docker" --team ICE-T --limit 20Options:
queryrequired search text--teamfilter by team key or name--status,-scomma-separated workflow states--projectproject name or UUID--assignee,-aassignee user ID--limit,-lmax results, default25
Create Ticket
uv run .claude/skills/linear/scripts/create-ticket.py "Fix CVE-2024-1234" \
--team ICE-T \
--description "Critical vulnerability in production" \
--priority 1 \
--labels "security" \
--jsonOptions:
titlerequired ticket title--teamrequired team key or name--description,-ddescription--priority,-p1=urgent,2=high,3=normal,4=low--labelscomma-separated label names or UUIDs--jsonprint structured JSON instead of just the identifier
Read Ticket
uv run .claude/skills/linear/scripts/read-ticket.py ICE-2021
uv run .claude/skills/linear/scripts/read-ticket.py 9e05263f-ed01-4b85-9c74-569fd1a0ce13Update Ticket
uv run .claude/skills/linear/scripts/update-ticket.py ICE-2021 --status "In Progress"
uv run .claude/skills/linear/scripts/update-ticket.py ICE-2021 --status "Done" --priority 2
uv run .claude/skills/linear/scripts/update-ticket.py ICE-2021 --labels "security,urgent"Options, at least one required:
--statusnew status name or UUID--priority1=urgent,2=high,3=normal,4=low--assigneeassignee user ID--labelscomma-separated label names or UUIDs; labels are added to the existing set--projectproject name or UUID--project-milestonemilestone name or UUID--titlenew title--descriptionnew description
Add Comment
uv run .claude/skills/linear/scripts/add-comment.py ICE-2021 "Fixed in PR #123"---
Project Operations
Create Project
uv run .claude/skills/linear/scripts/create-project.py "Security Remediation Q1" \
--team ICE-T \
--description "Eliminate all High severity alerts" \
--priority 1 \
--jsonOptions:
namerequired project name--teamrequired team key or name--descriptionshort summary, max 255 chars--contentfull markdown content for the project page--priority0=none,1=urgent,2=high,3=normal,4=low--target-dateYYYY-MM-DD--jsonprint full project JSON
Add Issues to Project
uv run .claude/skills/linear/scripts/add-issues-to-project.py PROJECT_UUID ICE-2027
uv run .claude/skills/linear/scripts/add-issues-to-project.py PROJECT_UUID ICE-2027 ICE-2028 ICE-2029
uv run .claude/skills/linear/scripts/add-issues-to-project.py PROJECT_UUID --issues ICE-2027,ICE-2028---
Milestone Operations
Create Milestone
uv run .claude/skills/linear/scripts/create-milestone.py "P1: Critical Fixes" \
--project PROJECT_UUID \
--description "RCE and exposed secrets" \
--target-date 2026-02-09 \
--jsonOptions:
namerequired milestone name--projectrequired project UUID--descriptionmilestone description--target-datedue date inYYYY-MM-DD--jsonprint full milestone JSON
Add Issues to Milestone
uv run .claude/skills/linear/scripts/add-issues-to-milestone.py MILESTONE_UUID ICE-2027 ICE-2028---
Document Operations
Create Document
uv run .claude/skills/linear/scripts/create-document.py \
--title "Security Findings Report" \
--project PROJECT_UUID \
--content-file ./report.md \
--jsonOptions:
--titlerequired document title--projectrequired project name or UUID--contentmarkdown content--content-fileread content from file--jsonprint structured JSON
List Documents
uv run .claude/skills/linear/scripts/list-documents.py --project PROJECT_UUID
uv run .claude/skills/linear/scripts/list-documents.py --project "Security Remediation" --limit 100Read Document
uv run .claude/skills/linear/scripts/read-document.py DOCUMENT_UUID---
Common Workflow
# 1. Create project
PROJECT_ID=$(uv run .claude/skills/linear/scripts/create-project.py \
"Security Remediation" \
--team ICE-T \
--description "Eliminate all security vulnerabilities")
# 2. Create milestone
MILESTONE_ID=$(uv run .claude/skills/linear/scripts/create-milestone.py \
"P1: Critical" \
--project "$PROJECT_ID" \
--target-date 2026-02-09)
# 3. Create ticket
TICKET_ID=$(uv run .claude/skills/linear/scripts/create-ticket.py \
"Patch CVE-2024-1234" \
--team ICE-T \
--priority 1)
# 4. Link ticket into the plan
uv run .claude/skills/linear/scripts/add-issues-to-project.py "$PROJECT_ID" "$TICKET_ID"
uv run .claude/skills/linear/scripts/add-issues-to-milestone.py "$MILESTONE_ID" "$TICKET_ID"
# 5. Attach supporting documentation
uv run .claude/skills/linear/scripts/create-document.py \
--title "Findings Report" \
--project "$PROJECT_ID" \
--content-file ./report.md---
Direct GraphQL Usage
If a needed operation does not have a wrapper yet, call the GraphQL endpoint directly rather than introducing a CLI dependency.
curl -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_TOKEN" \
-d '{"query":"{ viewer { id name } }"}'Do not use a Bearer prefix. Linear expects the raw token.
For reusable queries and resolver examples, see references/graphql-reference.md.
---
Known Limitations
- Due dates are not handled by the current project wrappers; use the UI if you need fields not exposed here
- Labels must already exist in the workspace
- Project
descriptionis capped at 255 characters; usecontentfor long-form project docs
---
Error Handling
All scripts exit with code 1 on errors.
Common failures:
Error: LINEAR_API_TOKEN not set and ~/.linear_api_token not foundError: Ticket ICE-9999 not foundError: Team 'INVALID' not foundError: Milestone 'Release 1' matched multiple projects (...)
---
Version
- Skill version:
3.0 - Transport:
Linear GraphQL endpoint only
Linear GraphQL Reference
The Linear skill now uses https://api.linear.app/graphql exclusively.
Authentication
Linear expects the raw API token in the Authorization header.
curl -X POST https://api.linear.app/graphql \
-H "Content-Type: application/json" \
-H "Authorization: $LINEAR_API_TOKEN" \
-d '{"query":"{ viewer { id name } }"}'Common Resolver Queries
Resolve a Team by Key or Name
query ResolveTeam($value: String!) {
byKey: teams(filter: { key: { eq: $value } }, first: 2) {
nodes { id key name }
}
byName: teams(filter: { name: { eq: $value } }, first: 2) {
nodes { id key name }
}
}Resolve a Project by Name
query ResolveProject($value: String!) {
projects(filter: { name: { eqIgnoreCase: $value } }, first: 2) {
nodes { id name url }
}
}Resolve a Workflow State by Name
query ResolveWorkflowState($value: String!, $teamId: String!) {
workflowStates(
filter: {
name: { eqIgnoreCase: $value }
team: { id: { eq: $teamId } }
}
first: 2
) {
nodes { id name }
}
}Resolve a Milestone by Name
query FindScopedMilestone($projectId: String!, $name: String!) {
project(id: $projectId) {
projectMilestones(filter: { name: { eq: $name } }, first: 10) {
nodes {
id
name
project { id name }
}
}
}
}Issue Queries and Mutations
Read an Issue by Identifier
query GetIssueByIdentifier($teamKey: String!, $number: Float!) {
issues(
filter: { team: { key: { eq: $teamKey } }, number: { eq: $number } }
first: 1
) {
nodes {
id
identifier
title
description
branchName
priority
url
state { id name }
team { id key name }
project { id name }
projectMilestone { id name targetDate }
labels { nodes { id name } }
comments { nodes { id body createdAt updatedAt } }
}
}
}List Issues
query ListIssues($first: Int!, $filter: IssueFilter) {
issues(first: $first, filter: $filter, includeArchived: false) {
nodes {
id
identifier
title
url
state { id name }
assignee { id name }
team { id key name }
project { id name }
labels { nodes { id name } }
}
}
}Search Issues
query SearchIssues($term: String!, $first: Int!, $filter: IssueFilter) {
searchIssues(
term: $term
first: $first
filter: $filter
includeArchived: false
) {
nodes {
id
identifier
title
url
state { id name }
}
}
}Create an Issue
mutation CreateIssue($input: IssueCreateInput!) {
issueCreate(input: $input) {
success
issue {
id
identifier
title
branchName
url
state { id name }
}
}
}Update an Issue
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
issue {
id
identifier
title
url
state { id name }
labels { nodes { id name } }
}
}
}Add a Comment
mutation CreateComment($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
}
}
}Project and Milestone Mutations
Create a Project
mutation CreateProject($input: ProjectCreateInput!) {
projectCreate(input: $input) {
success
project {
id
name
description
content
state
url
}
}
}Update Project Content
mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {
projectUpdate(id: $id, input: $input) {
success
}
}Create a Milestone
mutation CreateProjectMilestone($input: ProjectMilestoneCreateInput!) {
projectMilestoneCreate(input: $input) {
success
projectMilestone {
id
name
description
targetDate
}
}
}Document Queries and Mutations
Create a Document
mutation CreateDocument($input: DocumentCreateInput!) {
documentCreate(input: $input) {
success
document {
id
title
content
slugId
url
icon
color
createdAt
updatedAt
trashed
}
}
}List Documents
query ListDocuments($first: Int!, $filter: DocumentFilter) {
documents(first: $first, filter: $filter) {
nodes {
id
title
slugId
url
updatedAt
trashed
}
pageInfo {
hasNextPage
endCursor
}
}
}Read a Document
query GetDocument($id: String!) {
document(id: $id) {
id
title
content
slugId
url
icon
color
createdAt
updatedAt
trashed
}
}#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Add a comment to a Linear ticket using the Linear GraphQL API.
Usage:
uv run scripts/add-comment.py ICE-2021 "Fixed in PR #123"
Arguments:
ticket_id Ticket identifier (e.g., ICE-2021)
body Comment text
Returns:
JSON confirmation with ticket ID and comment status
"""
import argparse
import json
import sys
from linear_graphql import LinearError, add_comment
def main():
parser = argparse.ArgumentParser(description="Add a comment to a Linear ticket")
parser.add_argument("ticket_id", help="Ticket identifier (e.g., ICE-2021)")
parser.add_argument("body", help="Comment text")
args = parser.parse_args()
try:
result = add_comment(args.ticket_id, args.body)
print(json.dumps(result, separators=(",", ":")))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Add issues to a Linear project milestone.
Usage:
uv run scripts/add-issues-to-milestone.py MILESTONE_ID ISSUE_ID [ISSUE_ID ...]
uv run scripts/add-issues-to-milestone.py MILESTONE_ID --issues ICE-2027,ICE-2028
Options:
MILESTONE_ID Milestone UUID
ISSUE_ID Issue identifiers (e.g., ICE-2027) or UUIDs
--issues Comma-separated list of issue identifiers
Returns:
Count of successfully added issues
"""
import argparse
import sys
from linear_graphql import LinearError, set_issue_milestone
def main():
parser = argparse.ArgumentParser(description="Add issues to a project milestone")
parser.add_argument("milestone_id", help="Milestone UUID")
parser.add_argument("issue_ids", nargs="*", help="Issue identifiers")
parser.add_argument("--issues", help="Comma-separated issue identifiers")
args = parser.parse_args()
issue_ids = list(args.issue_ids) if args.issue_ids else []
if args.issues:
issue_ids.extend(args.issues.split(","))
if not issue_ids:
print("Error: No issue IDs provided", file=sys.stderr)
sys.exit(1)
success_count = 0
fail_count = 0
for issue_id in issue_ids:
issue_id = issue_id.strip()
if not issue_id:
continue
try:
if set_issue_milestone(issue_id, args.milestone_id):
success_count += 1
else:
print(f"Warning: Failed to add '{issue_id}'", file=sys.stderr)
fail_count += 1
except LinearError as exc:
print(f"Warning: {exc}", file=sys.stderr)
fail_count += 1
print(f"Added {success_count} issues to milestone ({fail_count} failed)")
if fail_count > 0 and success_count == 0:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Add issues to a Linear project using the GraphQL API.
Usage:
uv run scripts/add-issues-to-project.py PROJECT_ID ISSUE_ID [ISSUE_ID ...]
uv run scripts/add-issues-to-project.py PROJECT_ID --issues ICE-2027,ICE-2028,ICE-2029
Options:
PROJECT_ID Project UUID (from create-project.py or Linear UI)
ISSUE_ID Issue identifiers (e.g., ICE-2027) or UUIDs
--issues Comma-separated list of issue identifiers
Returns:
Count of successfully added issues
Example:
# Add single issue
uv run scripts/add-issues-to-project.py abc123-def456 ICE-2027
# Add multiple issues
uv run scripts/add-issues-to-project.py abc123-def456 ICE-2027 ICE-2028 ICE-2029
# Using comma-separated list
uv run scripts/add-issues-to-project.py abc123-def456 --issues ICE-2027,ICE-2028
"""
import argparse
import sys
from linear_graphql import LinearError, set_issue_project
def main():
parser = argparse.ArgumentParser(description="Add issues to a Linear project")
parser.add_argument("project_id", help="Project UUID")
parser.add_argument(
"issue_ids",
nargs="*",
help="Issue identifiers (e.g., ICE-2027)",
)
parser.add_argument(
"--issues",
help="Comma-separated list of issue identifiers",
)
args = parser.parse_args()
# Collect all issue IDs
issue_ids = list(args.issue_ids) if args.issue_ids else []
if args.issues:
issue_ids.extend(args.issues.split(","))
if not issue_ids:
print("Error: No issue IDs provided", file=sys.stderr)
sys.exit(1)
success_count = 0
fail_count = 0
for issue_id in issue_ids:
issue_id = issue_id.strip()
if not issue_id:
continue
try:
if set_issue_project(issue_id, args.project_id):
success_count += 1
else:
print(f"Warning: Failed to add '{issue_id}'", file=sys.stderr)
fail_count += 1
except LinearError as exc:
print(f"Warning: {exc}", file=sys.stderr)
fail_count += 1
print(f"Added {success_count} issues to project ({fail_count} failed)")
if fail_count > 0 and success_count == 0:
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Create a Linear document attached to a project using the GraphQL API.
Usage:
uv run scripts/create-document.py --title "Doc Title" --project PROJECT_ID [options]
uv run scripts/create-document.py --title "Doc Title" --project PROJECT_ID --content-file report.md
Options:
--title Document title (required)
--project Project name or ID (required)
--content Document content (markdown)
--content-file Read content from file
--json Output full JSON
Returns:
Without --json: Prints the document URL
With --json: Prints full JSON with id, title, url, etc.
"""
import argparse
import json
import sys
from pathlib import Path
from linear_graphql import LinearError, create_document
def main():
parser = argparse.ArgumentParser(description="Create a Linear document")
parser.add_argument("--title", required=True, help="Document title")
parser.add_argument("--project", required=True, help="Project name or ID")
parser.add_argument("--content", help="Document content (markdown)")
parser.add_argument("--content-file", help="Read content from file")
parser.add_argument("--json", action="store_true", dest="output_json")
args = parser.parse_args()
content = args.content
if args.content_file:
content_path = Path(args.content_file)
if not content_path.exists():
print(f"Error: File not found: {args.content_file}", file=sys.stderr)
sys.exit(1)
content = content_path.read_text()
try:
document = create_document(
title=args.title,
project=args.project,
content=content,
)
if args.output_json:
output = {
"id": document.get("id"),
"title": document.get("title"),
"url": document.get("url"),
"project": document.get("project"),
}
print(json.dumps(output, separators=(",", ":")))
else:
print(document.get("url", document.get("id", "")))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Create a Linear project milestone using the GraphQL API.
Usage:
uv run scripts/create-milestone.py "Milestone name" --project PROJECT_ID [options]
Options:
--project Project UUID (required)
--description Milestone description
--target-date Target date (YYYY-MM-DD)
--json Output full JSON instead of just ID
Returns:
Without --json: Prints the milestone ID
With --json: Prints full JSON with id, name, targetDate, etc.
Note:
This script uses the Linear GraphQL API directly.
Schema Reference:
https://studio.apollographql.com/public/Linear-API/variant/current/schema/reference
"""
import argparse
import json
import sys
from linear_graphql import LinearError, create_milestone
def main():
parser = argparse.ArgumentParser(
description="Create a Linear project milestone and return its ID"
)
parser.add_argument("name", help="Milestone name")
parser.add_argument("--project", required=True, help="Project UUID")
parser.add_argument("--description", "-d", help="Milestone description")
parser.add_argument("--target-date", help="Target date (YYYY-MM-DD)")
parser.add_argument(
"--json",
action="store_true",
dest="output_json",
help="Output full JSON instead of just ID",
)
args = parser.parse_args()
try:
milestone = create_milestone(
args.name,
args.project,
description=args.description,
target_date=args.target_date,
)
if args.output_json:
print(json.dumps(milestone, separators=(",", ":")))
else:
print(milestone.get("id", ""))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Create a Linear project using the Linear GraphQL API.
Usage:
uv run scripts/create-project.py "Project name" --team ICE-T [options]
Options:
--team Team key (required)
--description Short description (max 255 chars, shown in list views)
--content Full project content (markdown, shown in project page)
--priority Priority 0-4 (0=none, 1=urgent, 4=low)
--target-date Target date (YYYY-MM-DD)
--json Output full JSON instead of just ID
Returns:
Without --json: Prints the project ID
With --json: Prints full JSON with id, name, url, etc.
Note:
This script uses the Linear GraphQL API directly.
"""
import argparse
import json
import sys
from linear_graphql import LinearError, create_project
def main():
parser = argparse.ArgumentParser(
description="Create a Linear project and return its ID"
)
parser.add_argument("name", help="Project name")
parser.add_argument("--team", required=True, help="Team key (e.g., ICE-T)")
parser.add_argument("--description", "-d", help="Short description (max 255 chars)")
parser.add_argument("--content", "-c", help="Full project content (markdown)")
parser.add_argument(
"--priority",
"-p",
type=int,
choices=[0, 1, 2, 3, 4],
help="Priority (0=none, 1=urgent, 4=low)",
)
parser.add_argument("--target-date", help="Target date (YYYY-MM-DD)")
parser.add_argument(
"--json",
action="store_true",
dest="output_json",
help="Output full JSON instead of just ID",
)
args = parser.parse_args()
try:
project = create_project(
args.name,
args.team,
description=args.description,
content=args.content,
priority=args.priority,
target_date=args.target_date,
)
if args.output_json:
print(json.dumps(project, separators=(",", ":")))
else:
print(project.get("id", ""))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Create a Linear ticket using the Linear GraphQL API.
Usage:
uv run scripts/create-ticket.py "Issue title" --team ICE-T [options]
Options:
--team Team key (required)
--description Issue description
--priority Priority 1-4 (1=urgent, 4=low)
--labels Comma-separated labels
--json Output full JSON instead of just identifier
Returns:
Without --json: Prints the ticket identifier (e.g., ICE-2021)
With --json: Prints full JSON with identifier, title, branchName, state, url
"""
import argparse
import json
import sys
from linear_graphql import LinearError, create_ticket
def main():
parser = argparse.ArgumentParser(
description="Create a Linear ticket and return its identifier"
)
parser.add_argument("title", help="Issue title")
parser.add_argument("--team", required=True, help="Team key (e.g., ICE-T)")
parser.add_argument("--description", "-d", help="Issue description")
parser.add_argument(
"--priority",
"-p",
type=int,
choices=[1, 2, 3, 4],
help="Priority (1=urgent, 4=low)",
)
parser.add_argument("--labels", help="Comma-separated labels")
parser.add_argument(
"--json",
action="store_true",
dest="output_json",
help="Output full JSON instead of just identifier",
)
args = parser.parse_args()
try:
issue = create_ticket(
args.title,
args.team,
description=args.description,
priority=args.priority,
labels=args.labels,
)
if args.output_json:
output = {
"identifier": issue.get("identifier"),
"title": issue.get("title", args.title),
"branchName": issue.get("branchName", ""),
"state": issue.get("state", {}).get("name", "Backlog"),
"url": issue.get("url", ""),
}
print(json.dumps(output, separators=(",", ":")))
else:
print(issue.get("identifier", ""))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any
from urllib import error, request
LINEAR_GRAPHQL_ENDPOINT = "https://api.linear.app/graphql"
ISSUE_LIST_FIELDS = """
id
identifier
title
description
branchName
priority
createdAt
updatedAt
url
state {
id
name
}
assignee {
id
name
}
team {
id
key
name
}
project {
id
name
}
projectMilestone {
id
name
targetDate
}
labels {
nodes {
id
name
}
}
""".strip()
ISSUE_DETAIL_FIELDS = f"""
{ISSUE_LIST_FIELDS}
parent {{
id
identifier
title
}}
children {{
nodes {{
id
identifier
title
}}
}}
comments {{
nodes {{
id
body
createdAt
updatedAt
user {{
id
name
}}
}}
}}
""".strip()
DOCUMENT_FIELDS = """
id
title
content
slugId
url
icon
color
createdAt
updatedAt
trashed
""".strip()
class LinearError(RuntimeError):
"""Raised when a Linear GraphQL operation fails."""
def get_api_token() -> str | None:
if token := os.environ.get("LINEAR_API_TOKEN"):
return token.strip()
token_file = Path.home() / ".linear_api_token"
if token_file.exists():
return token_file.read_text().strip()
return None
def require_api_token() -> str:
token = get_api_token()
if token:
return token
raise LinearError(
"LINEAR_API_TOKEN not set and ~/.linear_api_token not found"
)
def is_uuid(value: str) -> bool:
if len(value) != 36:
return False
parts = value.split("-")
if not (len(parts) == 5 and [len(part) for part in parts] == [8, 4, 4, 4, 12]):
return False
try:
int(value.replace("-", ""), 16)
except ValueError:
return False
return True
def parse_issue_identifier(value: str) -> tuple[str, int] | None:
if "-" not in value:
return None
team_key, issue_number = value.rsplit("-", 1)
if not team_key or not issue_number.isdigit():
return None
return team_key, int(issue_number)
def graphql_request(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
token = require_api_token()
payload = json.dumps(
{
"query": query,
"variables": variables or {},
}
).encode("utf-8")
http_request = request.Request(
LINEAR_GRAPHQL_ENDPOINT,
data=payload,
headers={
"Authorization": token,
"Content-Type": "application/json",
},
method="POST",
)
try:
with request.urlopen(http_request) as response:
raw_body = response.read().decode("utf-8")
except error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace").strip()
raise LinearError(body or f"HTTP {exc.code} from Linear API") from exc
except error.URLError as exc:
raise LinearError(f"Unable to reach Linear API: {exc.reason}") from exc
try:
data = json.loads(raw_body)
except json.JSONDecodeError as exc:
raise LinearError(f"Invalid JSON from Linear API: {exc}") from exc
errors_list = data.get("errors") or []
if errors_list:
first_error = errors_list[0]
if isinstance(first_error, dict):
message = first_error.get("message", "Linear GraphQL request failed")
else:
message = str(first_error)
raise LinearError(message)
return data.get("data") or {}
def _single_node(nodes: list[dict[str, Any]], entity_name: str, value: str) -> dict[str, Any]:
if not nodes:
raise LinearError(f"{entity_name} '{value}' not found")
return nodes[0]
def resolve_team(team_key_or_name_or_id: str) -> dict[str, Any]:
if is_uuid(team_key_or_name_or_id):
return {"id": team_key_or_name_or_id, "key": None, "name": None}
data = graphql_request(
"""
query ResolveTeam($value: String!) {
byKey: teams(filter: { key: { eq: $value } }, first: 2) {
nodes { id key name }
}
byName: teams(filter: { name: { eq: $value } }, first: 2) {
nodes { id key name }
}
}
""",
{"value": team_key_or_name_or_id},
)
nodes = []
seen_ids: set[str] = set()
for bucket in (data.get("byKey", {}), data.get("byName", {})):
for node in bucket.get("nodes", []):
node_id = node.get("id")
if node_id and node_id not in seen_ids:
seen_ids.add(node_id)
nodes.append(node)
return _single_node(nodes, "Team", team_key_or_name_or_id)
def resolve_project(project_name_or_id: str) -> dict[str, Any]:
if is_uuid(project_name_or_id):
return {"id": project_name_or_id, "name": None, "url": None}
data = graphql_request(
"""
query ResolveProject($value: String!) {
projects(filter: { name: { eqIgnoreCase: $value } }, first: 2) {
nodes { id name url }
}
}
""",
{"value": project_name_or_id},
)
return _single_node(data.get("projects", {}).get("nodes", []), "Project", project_name_or_id)
def resolve_label_ids(label_names_or_ids: list[str]) -> list[str]:
label_ids: list[str] = []
for label in label_names_or_ids:
if not label:
continue
if is_uuid(label):
label_ids.append(label)
continue
data = graphql_request(
"""
query ResolveLabel($value: String!) {
issueLabels(filter: { name: { eqIgnoreCase: $value } }, first: 2) {
nodes { id name }
}
}
""",
{"value": label},
)
label_ids.append(
_single_node(data.get("issueLabels", {}).get("nodes", []), "Label", label)["id"]
)
return label_ids
def resolve_user_id(user_name_or_email_or_id: str) -> str:
if is_uuid(user_name_or_email_or_id):
return user_name_or_email_or_id
data = graphql_request(
"""
query ResolveUser($value: String!) {
byDisplayName: users(
filter: { displayName: { eqIgnoreCase: $value } }
first: 10
) {
nodes { id name displayName email }
}
byEmail: users(filter: { email: { eqIgnoreCase: $value } }, first: 2) {
nodes { id name displayName email }
}
}
""",
{"value": user_name_or_email_or_id},
)
by_name = data.get("byDisplayName", {}).get("nodes", [])
if len(by_name) == 1:
return by_name[0]["id"]
if len(by_name) > 1:
matches = ", ".join(
sorted(
{
f"{node.get('displayName') or node.get('name') or 'Unknown'} <{node.get('email', 'no-email')}>"
for node in by_name
}
)
)
raise LinearError(
f"User '{user_name_or_email_or_id}' matched multiple users ({matches}). Use email or UUID to disambiguate."
)
by_email = data.get("byEmail", {}).get("nodes", [])
if len(by_email) == 1:
return by_email[0]["id"]
raise LinearError(f"User '{user_name_or_email_or_id}' not found")
def resolve_workflow_state_ids(
statuses: list[str], team_id: str | None = None
) -> list[str]:
state_ids: list[str] = []
for status in statuses:
if not status:
continue
if is_uuid(status):
state_ids.append(status)
continue
if team_id:
query = """
query ResolveWorkflowState($value: String!, $teamId: String!) {
workflowStates(
filter: {
name: { eqIgnoreCase: $value }
team: { id: { eq: $teamId } }
}
first: 2
) {
nodes { id name }
}
}
"""
variables = {"value": status, "teamId": team_id}
else:
query = """
query ResolveWorkflowState($value: String!) {
workflowStates(filter: { name: { eqIgnoreCase: $value } }, first: 2) {
nodes { id name }
}
}
"""
variables = {"value": status}
data = graphql_request(query, variables)
state_ids.append(
_single_node(
data.get("workflowStates", {}).get("nodes", []), "Status", status
)["id"]
)
return state_ids
def resolve_issue(issue_id_or_identifier: str) -> dict[str, Any]:
if is_uuid(issue_id_or_identifier):
data = graphql_request(
f"""
query GetIssueById($id: String!) {{
issue(id: $id) {{
{ISSUE_DETAIL_FIELDS}
}}
}}
""",
{"id": issue_id_or_identifier},
)
issue = data.get("issue")
if not issue:
raise LinearError(f"Ticket {issue_id_or_identifier} not found")
return issue
parsed = parse_issue_identifier(issue_id_or_identifier)
if not parsed:
raise LinearError(
f"'{issue_id_or_identifier}' is not a valid ticket identifier or UUID"
)
team_key, issue_number = parsed
data = graphql_request(
f"""
query GetIssueByIdentifier($teamKey: String!, $number: Float!) {{
issues(
filter: {{ team: {{ key: {{ eq: $teamKey }} }}, number: {{ eq: $number }} }}
first: 1
) {{
nodes {{
{ISSUE_DETAIL_FIELDS}
}}
}}
}}
""",
{"teamKey": team_key, "number": issue_number},
)
nodes = data.get("issues", {}).get("nodes", [])
return _single_node(nodes, "Ticket", issue_id_or_identifier)
def resolve_issue_id(issue_id_or_identifier: str) -> str:
if is_uuid(issue_id_or_identifier):
return issue_id_or_identifier
return resolve_issue(issue_id_or_identifier)["id"]
def resolve_milestone_id(
milestone_name_or_id: str,
project_name_or_id: str | None = None,
) -> str:
if is_uuid(milestone_name_or_id):
return milestone_name_or_id
if project_name_or_id:
project = resolve_project(project_name_or_id)
data = graphql_request(
"""
query FindScopedMilestone($projectId: String!, $name: String!) {
project(id: $projectId) {
projectMilestones(filter: { name: { eq: $name } }, first: 10) {
nodes {
id
name
project { id name }
}
}
}
}
""",
{"projectId": project["id"], "name": milestone_name_or_id},
)
scoped_nodes = data.get("project", {}).get("projectMilestones", {}).get("nodes", [])
if scoped_nodes:
return _single_node(scoped_nodes, "Milestone", milestone_name_or_id)["id"]
data = graphql_request(
"""
query FindGlobalMilestone($name: String!) {
projectMilestones(filter: { name: { eq: $name } }, first: 10) {
nodes {
id
name
project { id name }
}
}
}
""",
{"name": milestone_name_or_id},
)
nodes = data.get("projectMilestones", {}).get("nodes", [])
if len(nodes) > 1:
project_names = ", ".join(
sorted(
{
node.get("project", {}).get("name", "unknown project")
for node in nodes
}
)
)
raise LinearError(
f"Milestone '{milestone_name_or_id}' matched multiple projects ({project_names}). Provide the milestone ID or scope it with --project."
)
return _single_node(nodes, "Milestone", milestone_name_or_id)["id"]
def build_issue_filter(
*,
team_id: str | None = None,
project_id: str | None = None,
assignee_id: str | None = None,
state_ids: list[str] | None = None,
) -> dict[str, Any]:
fragments: list[dict[str, Any]] = []
if state_ids:
fragments.append({"state": {"id": {"in": state_ids}}})
else:
fragments.append({"state": {"type": {"neq": "completed"}}})
if team_id:
fragments.append({"team": {"id": {"eq": team_id}}})
if project_id:
fragments.append({"project": {"id": {"eq": project_id}}})
if assignee_id:
fragments.append({"assignee": {"id": {"eq": assignee_id}}})
if len(fragments) == 1:
return fragments[0]
return {"and": fragments}
def list_issues(
*,
team: str | None = None,
limit: int = 50,
status: str | None = None,
project: str | None = None,
assignee: str | None = None,
) -> list[dict[str, Any]]:
team_id = resolve_team(team)["id"] if team else None
project_id = resolve_project(project)["id"] if project else None
assignee_id = resolve_user_id(assignee) if assignee else None
state_ids = (
resolve_workflow_state_ids([part.strip() for part in status.split(",")], team_id)
if status
else None
)
data = graphql_request(
f"""
query ListIssues($first: Int!, $filter: IssueFilter) {{
issues(first: $first, filter: $filter, includeArchived: false) {{
nodes {{
{ISSUE_LIST_FIELDS}
}}
}}
}}
""",
{
"first": limit,
"filter": build_issue_filter(
team_id=team_id,
project_id=project_id,
assignee_id=assignee_id,
state_ids=state_ids,
),
},
)
return data.get("issues", {}).get("nodes", [])
def search_issues(
term: str,
*,
team: str | None = None,
status: str | None = None,
project: str | None = None,
assignee: str | None = None,
limit: int = 25,
) -> list[dict[str, Any]]:
team_id = resolve_team(team)["id"] if team else None
project_id = resolve_project(project)["id"] if project else None
assignee_id = resolve_user_id(assignee) if assignee else None
state_ids = (
resolve_workflow_state_ids([part.strip() for part in status.split(",")], team_id)
if status
else None
)
data = graphql_request(
f"""
query SearchIssues($term: String!, $first: Int!, $filter: IssueFilter) {{
searchIssues(
term: $term
first: $first
filter: $filter
includeArchived: false
) {{
nodes {{
{ISSUE_LIST_FIELDS}
}}
}}
}}
""",
{
"term": term,
"first": limit,
"filter": build_issue_filter(
team_id=team_id,
project_id=project_id,
assignee_id=assignee_id,
state_ids=state_ids,
),
},
)
return data.get("searchIssues", {}).get("nodes", [])
def create_ticket(
title: str,
team: str,
*,
description: str | None = None,
priority: int | None = None,
labels: str | None = None,
) -> dict[str, Any]:
team_node = resolve_team(team)
label_ids = resolve_label_ids([part.strip() for part in labels.split(",")]) if labels else None
input_data: dict[str, Any] = {"title": title, "teamId": team_node["id"]}
if description:
input_data["description"] = description
if priority is not None:
input_data["priority"] = priority
if label_ids:
input_data["labelIds"] = label_ids
data = graphql_request(
f"""
mutation CreateIssue($input: IssueCreateInput!) {{
issueCreate(input: $input) {{
success
issue {{
{ISSUE_DETAIL_FIELDS}
}}
}}
}}
""",
{"input": input_data},
)
issue = data.get("issueCreate", {}).get("issue")
if not issue:
raise LinearError("Failed to create issue")
return issue
def update_ticket(issue_id_or_identifier: str, updates: dict[str, Any]) -> dict[str, Any]:
issue = resolve_issue(issue_id_or_identifier)
issue_id = issue["id"]
input_data: dict[str, Any] = {}
if updates.get("status") is not None:
state_ids = resolve_workflow_state_ids([updates["status"]], issue.get("team", {}).get("id"))
input_data["stateId"] = state_ids[0]
if updates.get("priority") is not None:
input_data["priority"] = updates["priority"]
if updates.get("assignee") is not None:
input_data["assigneeId"] = resolve_user_id(updates["assignee"])
if updates.get("project") is not None:
input_data["projectId"] = resolve_project(updates["project"])["id"]
if updates.get("project_milestone") is not None:
milestone_scope = updates.get("project") or issue.get("project", {}).get("id") or issue.get("project", {}).get("name")
input_data["projectMilestoneId"] = resolve_milestone_id(
updates["project_milestone"],
milestone_scope,
)
if updates.get("title") is not None:
input_data["title"] = updates["title"]
if updates.get("description") is not None:
input_data["description"] = updates["description"]
if updates.get("labels") is not None:
new_label_ids = resolve_label_ids(
[part.strip() for part in str(updates["labels"]).split(",") if part.strip()]
)
current_label_ids = [
node["id"] for node in issue.get("labels", {}).get("nodes", []) if node.get("id")
]
input_data["labelIds"] = sorted(set(current_label_ids + new_label_ids))
data = graphql_request(
f"""
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {{
issueUpdate(id: $id, input: $input) {{
success
issue {{
{ISSUE_DETAIL_FIELDS}
}}
}}
}}
""",
{"id": issue_id, "input": input_data},
)
updated_issue = data.get("issueUpdate", {}).get("issue")
if not updated_issue:
raise LinearError("Failed to update issue")
return updated_issue
def add_comment(issue_id_or_identifier: str, body: str) -> dict[str, Any]:
issue = resolve_issue(issue_id_or_identifier)
data = graphql_request(
"""
mutation CreateComment($input: CommentCreateInput!) {
commentCreate(input: $input) {
success
comment {
id
}
}
}
""",
{"input": {"issueId": issue["id"], "body": body}},
)
comment = data.get("commentCreate", {}).get("comment")
if not comment:
raise LinearError("Failed to create comment")
return {
"ticket": issue.get("identifier", issue_id_or_identifier),
"commented": True,
"commentId": comment.get("id", ""),
"url": issue.get("url", ""),
}
def create_project(
name: str,
team: str,
*,
description: str | None = None,
content: str | None = None,
priority: int | None = None,
target_date: str | None = None,
) -> dict[str, Any]:
team_node = resolve_team(team)
input_data: dict[str, Any] = {"name": name, "teamIds": [team_node["id"]]}
if description:
if len(description) > 255:
print(
f"Warning: Description truncated to 255 chars (was {len(description)})",
file=sys.stderr,
)
description = description[:252] + "..."
input_data["description"] = description
if priority is not None:
input_data["priority"] = priority
if target_date:
input_data["targetDate"] = target_date
data = graphql_request(
"""
mutation CreateProject($input: ProjectCreateInput!) {
projectCreate(input: $input) {
success
project {
id
name
description
content
state
url
}
}
}
""",
{"input": input_data},
)
project_node = data.get("projectCreate", {}).get("project")
if not project_node:
raise LinearError("Failed to create project")
if content:
graphql_request(
"""
mutation UpdateProject($id: String!, $input: ProjectUpdateInput!) {
projectUpdate(id: $id, input: $input) {
success
}
}
""",
{"id": project_node["id"], "input": {"content": content}},
)
project_node["content"] = content
return project_node
def create_milestone(
name: str,
project_id: str,
*,
description: str | None = None,
target_date: str | None = None,
) -> dict[str, Any]:
input_data: dict[str, Any] = {"name": name, "projectId": project_id}
if description:
input_data["description"] = description
if target_date:
input_data["targetDate"] = target_date
data = graphql_request(
"""
mutation CreateProjectMilestone($input: ProjectMilestoneCreateInput!) {
projectMilestoneCreate(input: $input) {
success
projectMilestone {
id
name
description
targetDate
}
}
}
""",
{"input": input_data},
)
milestone = data.get("projectMilestoneCreate", {}).get("projectMilestone")
if not milestone:
raise LinearError("Failed to create milestone")
return milestone
def set_issue_project(issue_id_or_identifier: str, project_id: str) -> bool:
issue_id = resolve_issue_id(issue_id_or_identifier)
data = graphql_request(
"""
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
}
}
""",
{"id": issue_id, "input": {"projectId": project_id}},
)
return bool(data.get("issueUpdate", {}).get("success"))
def set_issue_milestone(issue_id_or_identifier: str, milestone_id: str) -> bool:
issue_id = resolve_issue_id(issue_id_or_identifier)
data = graphql_request(
"""
mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
issueUpdate(id: $id, input: $input) {
success
}
}
""",
{"id": issue_id, "input": {"projectMilestoneId": milestone_id}},
)
return bool(data.get("issueUpdate", {}).get("success"))
def create_document(
*,
title: str,
project: str,
content: str | None = None,
) -> dict[str, Any]:
project_node = resolve_project(project)
input_data: dict[str, Any] = {
"title": title,
"projectId": project_node["id"],
}
if content:
input_data["content"] = content
data = graphql_request(
f"""
mutation CreateDocument($input: DocumentCreateInput!) {{
documentCreate(input: $input) {{
success
document {{
{DOCUMENT_FIELDS}
}}
}}
}}
""",
{"input": input_data},
)
document = data.get("documentCreate", {}).get("document")
if not document:
raise LinearError("Failed to create document")
document["project"] = project_node.get("name") or project
return document
def list_documents(*, project: str | None = None, limit: int = 50) -> dict[str, Any]:
filter_input = None
if project:
project_node = resolve_project(project)
filter_input = {"project": {"id": {"eq": project_node["id"]}}}
data = graphql_request(
f"""
query ListDocuments($first: Int!, $filter: DocumentFilter) {{
documents(first: $first, filter: $filter) {{
nodes {{
{DOCUMENT_FIELDS}
}}
pageInfo {{
hasNextPage
endCursor
}}
}}
}}
""",
{"first": limit, "filter": filter_input},
)
return data.get("documents", {"nodes": [], "pageInfo": {"hasNextPage": False, "endCursor": None}})
def read_document(document_id: str) -> dict[str, Any]:
data = graphql_request(
f"""
query GetDocument($id: String!) {{
document(id: $id) {{
{DOCUMENT_FIELDS}
}}
}}
""",
{"id": document_id},
)
document = data.get("document")
if not document:
raise LinearError(f"Document {document_id} not found")
return document
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
List Linear documents using the GraphQL API.
Usage:
uv run scripts/list-documents.py --project PROJECT_ID
uv run scripts/list-documents.py --project "Security Remediation" --limit 100
Returns:
JSON object with `nodes` and `pageInfo`.
"""
import argparse
import json
import sys
from linear_graphql import LinearError, list_documents
def main():
parser = argparse.ArgumentParser(description="List Linear documents")
parser.add_argument("--project", help="Filter by project name or ID")
parser.add_argument(
"--limit",
"-l",
type=int,
default=50,
help="Max documents to fetch (default: 50)",
)
args = parser.parse_args()
try:
documents = list_documents(project=args.project, limit=args.limit)
print(json.dumps(documents, separators=(",", ":")))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
List Linear issues for a team using the Linear GraphQL API.
Usage:
uv run scripts/list-issues.py --team ICE-T
uv run scripts/list-issues.py --team ICE-T --limit 50
uv run scripts/list-issues.py --team ICE-T --status "Todo,In Progress"
uv run scripts/list-issues.py --team ICE-T --project "Orca Security Remediation"
Returns:
JSON array of issues, each with: identifier, title, state, priority,
assignee, project, projectMilestone, labels, createdAt, updatedAt, url.
"""
import argparse
import json
import sys
from linear_graphql import LinearError, list_issues
def main():
parser = argparse.ArgumentParser(
description="List Linear issues, optionally filtered by team/status/project"
)
parser.add_argument("--team", help="Filter by team key or name (e.g., ICE-T)")
parser.add_argument(
"--limit",
"-l",
type=int,
default=50,
help="Max issues to fetch (default: 50)",
)
parser.add_argument(
"--status",
"-s",
help="Filter by status (comma-separated, e.g., 'Todo,In Progress')",
)
parser.add_argument(
"--project",
help="Filter by project name or ID",
)
args = parser.parse_args()
try:
issues = list_issues(
team=args.team,
limit=args.limit,
status=args.status,
project=args.project,
)
print(json.dumps(issues, separators=(",", ":")))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Read a Linear document using the GraphQL API.
Usage:
uv run scripts/read-document.py DOCUMENT_ID
"""
import argparse
import json
import sys
from linear_graphql import LinearError, read_document
def main():
parser = argparse.ArgumentParser(description="Read a Linear document")
parser.add_argument("document_id", help="Document UUID")
args = parser.parse_args()
try:
document = read_document(args.document_id)
print(json.dumps(document, separators=(",", ":")))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Read a Linear ticket's details using the Linear GraphQL API.
Usage:
uv run scripts/read-ticket.py ICE-2021
Returns:
Full ticket JSON including: identifier, title, description,
branchName, state, team, project, projectMilestone, priority,
labels, subIssues, comments, and url.
"""
import argparse
import json
import sys
from linear_graphql import LinearError, resolve_issue
def main():
parser = argparse.ArgumentParser(description="Read a Linear ticket's details")
parser.add_argument("ticket_id", help="Ticket identifier (e.g., ICE-2021)")
args = parser.parse_args()
try:
issue = resolve_issue(args.ticket_id)
print(json.dumps(issue, separators=(",", ":")))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Search Linear issues by query string using the Linear GraphQL API.
Usage:
uv run scripts/search-issues.py "Orca Security"
uv run scripts/search-issues.py "CVE" --team ICE-T
uv run scripts/search-issues.py "Privileged Role" --status "Todo,Triage"
uv run scripts/search-issues.py "Docker" --team ICE-T --limit 20
Returns:
JSON array of matching issues, each with: identifier, title, state,
priority, assignee, project, projectMilestone, labels, createdAt,
updatedAt, url.
"""
import argparse
import json
import sys
from linear_graphql import LinearError, search_issues
def main():
parser = argparse.ArgumentParser(description="Search Linear issues by query string")
parser.add_argument("query", help="Search query (e.g., 'Orca Security', 'CVE')")
parser.add_argument("--team", help="Filter by team key or name (e.g., ICE-T)")
parser.add_argument(
"--status",
"-s",
help="Filter by status (comma-separated, e.g., 'Todo,In Progress')",
)
parser.add_argument(
"--project",
help="Filter by project name or ID",
)
parser.add_argument(
"--assignee",
"-a",
help="Filter by assignee user ID",
)
parser.add_argument(
"--limit",
"-l",
type=int,
default=25,
help="Max results to return (default: 25)",
)
args = parser.parse_args()
try:
issues = search_issues(
args.query,
team=args.team,
status=args.status,
project=args.project,
assignee=args.assignee,
limit=args.limit,
)
print(json.dumps(issues, separators=(",", ":")))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = []
# ///
"""
Update a Linear ticket using the Linear GraphQL API.
Usage:
uv run scripts/update-ticket.py ICE-2021 --status "In Progress"
uv run scripts/update-ticket.py ICE-2021 --status "Done" --priority 2
uv run scripts/update-ticket.py ICE-2021 --labels "security,urgent"
At least one update flag is required.
Returns:
Full JSON of the updated ticket.
"""
import argparse
import json
import sys
from linear_graphql import LinearError, update_ticket
def main():
parser = argparse.ArgumentParser(description="Update a Linear ticket")
parser.add_argument("ticket_id", help="Ticket identifier (e.g., ICE-2021)")
parser.add_argument("--status", help="New status (e.g., 'In Progress', 'Done')")
parser.add_argument(
"--priority",
type=int,
choices=[1, 2, 3, 4],
help="Priority: 1=urgent, 2=high, 3=normal, 4=low",
)
parser.add_argument("--assignee", help="Assignee user ID")
parser.add_argument("--labels", help="Comma-separated label names")
parser.add_argument("--project", help="Project name or ID")
parser.add_argument(
"--project-milestone", dest="project_milestone", help="Milestone name or ID"
)
parser.add_argument("--title", help="New title")
parser.add_argument("--description", help="New description")
args = parser.parse_args()
updates = {
k: v for k, v in vars(args).items() if k != "ticket_id" and v is not None
}
if not updates:
parser.error(
"At least one update flag is required (e.g., --status, --priority)"
)
try:
issue = update_ticket(args.ticket_id, updates)
issue["updated"] = True
print(json.dumps(issue, separators=(",", ":")))
except LinearError as exc:
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()