gtm-linear
Async-first Python SDK for the Linear GraphQL API. Thin, typed wrapper around httpx with optional sync support, Strawberry-typed models, explicit error semantics, and a bundled read-only CLI.
Status: Alpha (
0.4.0, seepyproject.tomlandCHANGELOG.md). API surface is small but incomplete — fall back to rawLinearClient.execute_asyncfor anything not yet wrapped.
When to use this (agent triage)
| Situation | Use this SDK? |
|---|---|
| Read/write Linear issues from Python with typed responses | Yes |
| Need ad-hoc GraphQL escape hatch alongside typed helpers | Yes — LinearClient.execute_async(query, variables) |
| Building MCP-style tooling against Linear | Yes (low-level), or prefer the official Linear MCP server for higher-level intent |
| Need full coverage of Linear's GraphQL schema | No — only a focused subset of Linear types is wrapped today |
| Need webhooks, OAuth flow, or attachments | No — not implemented |
| Writing a one-off shell command | Yes — the bundled CLI: uvx gtm-linear issues --team ENG |
If you only need to create or read a few issues from an automation, this is the right tool. If you need broad schema coverage, drop down to execute_async with a hand-written query.
Install
uv pip install gtm-linear # from PyPI (>= 0.3.0)
# or, in this repo:
uv sync
Installing the package also installs the gtm-linear console command — see CLI.
Requires Python >=3.11. Runtime deps: httpx>=0.27, pydantic>=2.0, pydantic-settings>=2.14.2, typer>=0.27 (imported only by the CLI module — import gtm_linear never touches it). Typer is unconditional rather than a [cli] extra so uvx gtm-linear and uv tool install gtm-linear need no extra syntax — a deliberate install-footprint trade-off for library-only consumers. The optional [strawberry] extra (ships the generated schema mirror) adds strawberry-graphql>=0.328.0, which pulls graphql-core>=3.3,<3.4.
Auth
Linear personal API key. Format: lin_api_.... Pass the raw key as the Authorization header value (no Bearer prefix — Linear accepts the key directly).
export LINEAR_API_KEY=lin_api_xxx
The SDK does not read env vars on its own. Caller is responsible for passing api_key= to LinearClient. The key is stripped of surrounding whitespace at construction — a trailing newline from a secret store is harmless instead of an Illegal header value crash — and anything that is not a str/SecretStr (a LinearClient or LinearSettings object, say) raises TypeError immediately rather than failing later inside httpx. Blank or corrupted keys (embedded whitespace, control, or non-ASCII characters) raise ValueError at the same point.
CLI
The package ships a CLI as the gtm-linear console command. It wraps
LinearWorkflow, so typed reads and opt-in writes are available without writing
Python:
# from a checkout
uv run gtm-linear viewer
# one-off, no local install (PyPI >= 0.3.0 ships the executable)
uvx gtm-linear issues --team ENG --limit 10
# fetch every matching issue, with useful triage filters
uvx gtm-linear issues --team ENG --all --priority High --assignee me
# or install it as a tool
uv tool install gtm-linear
gtm-linear search "onboarding"
gtm-linear search onboarding --all
Auth uses the SDK's LinearSettings resolution: LINEAR_API_KEY (lin_api_...) from the environment or a .env / .env.local file in the working directory. Endpoint overrides (LINEAR_BASE_URL, LINEAR_TIMEOUT) are honored from the real environment only — a dotenv file may supply the key, but never redirect where it is sent. Real environment variables also take precedence over dotenv for the key itself; note that a dotenv file in the current directory is trusted for auth, so run the CLI from directories you control.
| Command | Purpose |
|---|---|
gtm-linear viewer |
Auth check: print the user the API key belongs to |
gtm-linear teams |
List teams (key, name, id) |
gtm-linear issues --team ENG [--state open|all|NAME] [--priority VALUE] [--assignee NAME|me] [--label NAME] [--limit N] [--all] [-v] |
List a team's issues, newest updated first (team key is case-insensitive; default state: open, limit: 25). State names, assignees, and labels match exactly; priority accepts Urgent/High/Medium/Low or 0–4. |
gtm-linear issue ENG-123 |
Fetch one issue by identifier (any casing) or Linear UUID |
gtm-linear issue create --team ENG --title "..." [--description ...] [--priority 0-4] [--assignee-id UUID] [--project-id UUID] [--state-id UUID] |
Preview an issue creation; resolve the team key and show the typed mutation payload |
gtm-linear issue update ENG-123 [fields...] |
Preview changes to title, description, priority, assignee, project, or state; use --clear-description, --clear-priority, --clear-assignee, --clear-project, or --clear-state to clear nullable fields |
gtm-linear issue comment ENG-123 --body "..." |
Preview a Markdown comment on an issue |
gtm-linear search "term" [--limit N] [--all] [-v] |
Free-text issue search across the workspace (multi-word terms may be unquoted — words are joined; dash-prefixed values are searched as-is, so a mistyped flag becomes the term) |
Write commands are non-interactive and preview by default: target resolution may issue read queries, but no mutation is sent unless --apply is present. Add --apply to any create, update, or comment command to execute it. Creation requires a team key and title; updates require at least one field or clear flag. Assignee, project, and workflow-state options take Linear IDs. --json previews return an envelope with applied: false, operation, target, and payload; successful applied writes return the created or updated resource. If a mutation call fails, JSON mode emits an error envelope with applied: false, operation, target, and error, then exits 1. No write command reads stdin or prompts for confirmation.
Every command accepts --json for machine-readable output. issues and search default to 25 and 10 results respectively; --limit accepts 1–100, and --all cannot be combined with --limit. Human-readable listings say whether all matching results were fetched or more may remain. Existing bounded JSON output remains an array; successful --all --json emits a results array with complete and truncated booleans. If pagination stalls in JSON mode, the CLI emits an error envelope containing any results fetched so far, complete: false, truncated: true, and the error, then exits 1. Without JSON mode, runtime failures use a single red error: … line on stderr and never a traceback; a closed output pipe, as in | head, exits 1 without printing an error. Exit codes: 0 success, 1 runtime failure, 130 Ctrl-C, 2 usage error.
Mental model
Three classes, all importable from the package root:
LinearClient # transport + auth + GraphQL execution
├── LinearQueries # typed read wrappers (get_issue, list_issues, search_issues, get_team, get_user)
└── LinearMutations # typed write wrappers (issues and comments)
LinearWorkflow # injected-key CLI facade over every typed read/write helper
LinearQueries and LinearMutations are stateless facades over a LinearClient. They do not own the client; they borrow it. Construct one client and pass it to both.
The low-level query and mutation methods are async-only: await each operation, or
use async for with an iter_* method. For synchronous scripts, use the
LinearWorkflow facade shown below.
Pydantic models validate Linear response payloads and mutation inputs internally. Strawberry's Pydantic integration exposes those validated models as the public GraphQL types and inputs.
import asyncio
from gtm_linear import (
IssueCreateInput,
LinearClient,
LinearMutations,
LinearQueries,
PaginationOrderBy,
)
async def main() -> None:
async with LinearClient(api_key="lin_api_xxx") as client:
queries = LinearQueries(client)
mutations = LinearMutations(client)
team = await queries.get_team_by_key("ENG")
assert team is not None
issue_filter = {"team": {"id": {"eq": team.id}}}
issues = await queries.list_issues_page(
issue_filter,
first=20,
order_by=PaginationOrderBy.updatedAt,
)
created = await mutations.create_issue(
IssueCreateInput(title="Hello", teamId=team.id, description="from agent"),
)
asyncio.run(main())
CLI and automation facade
For a command-line tool or automation that receives an API key from its own
configuration or secret manager, use LinearWorkflow. It owns the client and
provides matching synchronous and async methods; synchronous methods are for
normal CLI entrypoints, while *_async methods are for an existing event loop.
from gtm_linear import IssueCreateInput, LinearWorkflow
with LinearWorkflow(injected_api_key) as linear:
team = linear.get_team_by_key("ENG")
assert team is not None
open_issues = linear.list_open_team_issues(team.id)
issue = linear.create_issue(
IssueCreateInput(title="Investigate alert", team_id=team.id)
)
list_open_team_issues excludes completed and canceled states and orders by most
recent update. For ad-hoc GraphQL not yet represented by the typed API, access
linear.client.execute(...) or use LinearClient directly.
Public API surface
Importable from gtm_linear:
| Symbol | Kind | Purpose |
|---|---|---|
LinearClient |
class | Transport + auth + raw GraphQL execution |
LinearQueries |
class | Typed read helpers |
LinearMutations |
class | Typed write helpers |
LinearWorkflow |
class | Injected-key sync/async facade for CLI workflows |
LinearAPIError |
exception | Raised on HTTP non-200 OR GraphQL errors field present |
LinearPaginationError |
exception | Raised by iter_* when a connection stalls: several consecutive empty pages while hasNextPage stays true |
LinearWorkflowStateLookupError |
exception | Raised when a state-type lookup finds zero or multiple matching workflow states |
Issue |
model | Linear issue |
Attachment |
model | Issue attachment linking an external URL |
Comment |
model | Linear issue comment |
IssueRelation |
model | Relationship between two issues |
IssueContextComment |
model | Comment body, author(s), URL, and creation time |
IssueContextAttachment |
model | Attachment title, URL, source, and metadata |
IssueContextRelation |
model | Relation type and both issue endpoints |
IssueConnection |
model | Paginated issue list (nodes, pageInfo) |
IssueCommentConnection, IssueAttachmentConnection |
model | Paginated issue context collections |
IssueRelationConnection, IssueInverseRelationConnection |
model | Paginated outgoing and incoming issue relations |
IssueCreateInput |
input | title, teamId, optional description, labelIds, priority, assigneeId, projectId, stateId |
IssueUpdateInput |
input | Optional title, description, labelIds, priority, assigneeId, projectId, stateId |
AttachmentCreateInput |
input | Required issueId, url, title; optional subtitle and schema-supported metadata |
IssueRelationCreateInput |
input | Required issueId, relatedIssueId, type (IssueRelationType) |
IssueRelationType |
enum | Supported relation values: blocks, duplicate, related, similar |
| Issue filter mapping | dict[str, Any] |
Linear-shaped nested issue filter passed through to GraphQL |
PaginationOrderBy |
enum | Supported issue connection ordering (createdAt, updatedAt) |
Team |
model | id, name, key |
TeamConnection |
model | Paginated teams |
User |
model | id, name, email, active |
UserConnection |
model | Paginated users |
Project |
model | id, name, slug |
ProjectConnection |
model | Paginated projects |
WorkflowState |
model | Linear workflow state (id, name, type, color, position) |
WorkflowStateConnection |
model | Paginated workflow states |
PageInfo |
model | hasNextPage, hasPreviousPage, startCursor, endCursor |
The public Strawberry types are backed by Pydantic models, so malformed API payloads and invalid mutation inputs fail validation before they are exposed to callers or sent to Linear.
IssueCreateInput and IssueUpdateInput are Strawberry input types backed by Pydantic models. Construct positionally or with kwargs; some static type checkers may flag the call signature — the scripts/smoke.py file demonstrates the working ignore pattern. Optional fields set to None are omitted from mutation variables.
LinearClient reference
LinearClient(api_key: str)
State:
BASE_URL = "https://api.linear.app/graphql"(class attribute, overrideable on subclasses or by monkeypatch in tests)- Lazily creates an
httpx.Client(sync) andhttpx.AsyncClient(async) on first use. - Connection reuse: both clients persist across calls until their respective close method or context-manager exit.
Methods
| Method | Sync/Async | Returns | Raises |
|---|---|---|---|
execute(query, variables=None) |
sync | dict[str, Any] — the data payload |
LinearAPIError |
execute_async(query, variables=None) |
async | dict[str, Any] — the data payload |
LinearAPIError |
close() |
sync | None |
RuntimeError if an async client is still open |
aclose() |
async | None |
— |
__enter__ / __exit__ |
sync ctx mgr | — | — |
__aenter__ / __aexit__ |
async ctx mgr | — | — |
Error contract
execute / execute_async raise LinearAPIError if either:
- The response JSON contains a top-level
errorskey (GraphQL-level failure), OR - The HTTP status is not 200, OR
- The response is not parseable JSON / not a dict.
LinearAPIError.errors is the raw list of error dicts from Linear; LinearAPIError.message is a human-readable summary. Inspect .errors to recover structured codes.
Return value
The methods strip the outer {"data": ...} envelope and return the inner dict. So for a query of query { viewer { id } }, you get back {"viewer": {"id": "..."}}.
Client pitfalls
- Use
close()for sync-only use andawait aclose()for async-only use.close()raisesRuntimeErrorif an async client remains open, so it cannot silently leak the async connection pool.async withcloses both transports if they were created. BASE_URLis the production Linear endpoint. There is no staging URL toggle.- The
Authorizationheader is set to the raw API key string. Linear expects noBearerprefix; do not add one.
LinearQueries reference
Coroutine methods are async; iter_* methods return async iterators and are
consumed with async for. All accept Linear UUIDs unless noted; issue-context
methods also accept human-readable issue identifiers.
| Method | Args | Returns | Notes |
|---|---|---|---|
get_issue(issue_id) |
str |
Issue | None |
Returns None on not-found (not an error) |
list_issue_comments_page(issue_id, first=50, after=None, include_archived=False) |
str, int, str | None, bool |
IssueCommentConnection |
One typed comment page, including workspace or external author |
iter_issue_comments(issue_id, page_size=50, limit=None, include_archived=False) |
str, int, int | None, bool |
AsyncIterator[IssueContextComment] |
Follows every comment page automatically |
list_issue_attachments_page(issue_id, first=50, after=None, include_archived=False) |
str, int, str | None, bool |
IssueAttachmentConnection |
One typed attachment page with source metadata |
iter_issue_attachments(issue_id, page_size=50, limit=None, include_archived=False) |
str, int, int | None, bool |
AsyncIterator[IssueContextAttachment] |
Follows every attachment page automatically |
list_issue_relations_page(issue_id, first=50, after=None, include_archived=False) |
str, int, str | None, bool |
IssueRelationConnection |
One page of outgoing relations |
iter_issue_relations(issue_id, page_size=50, limit=None, include_archived=False) |
str, int, int | None, bool |
AsyncIterator[IssueContextRelation] |
Follows every outgoing relation page |
list_issue_inverse_relations_page(issue_id, first=50, after=None, include_archived=False) |
str, int, str | None, bool |
IssueInverseRelationConnection |
One page of incoming relations |
iter_issue_inverse_relations(issue_id, page_size=50, limit=None, include_archived=False) |
str, int, int | None, bool |
AsyncIterator[IssueContextRelation] |
Follows every incoming relation page |
list_issues(team_id, first=50) |
str, int |
list[Issue] |
Compatibility helper for a team's first issue page |
list_issues_page(filter=None, first=50, after=None, order_by=None, include_archived=False) |
dict[str, Any] | None, int, str | None, PaginationOrderBy | None, bool |
IssueConnection |
Root issue connection with cursor pagination |
search_issues(term) |
str |
list[Issue] |
Backed by Linear's searchIssues GraphQL field |
get_team(team_id) |
str |
Team | None |
UUID only; use get_team_by_key for ENG-style keys |
get_team_by_key(key) |
str |
Team | None |
Resolves a human team key such as ENG |
get_user(user_id) |
str |
User | None |
— |
list_workflow_states(team_id, first=50) |
str, int |
list[WorkflowState] |
Convenience helper for a team's first workflow-state page; use iter_workflow_states for all pages |
list_workflow_states_page(team_id, first=50, after=None, include_archived=False, order_by=None) |
str, int, str | None, bool, PaginationOrderBy | None |
WorkflowStateConnection |
Team-scoped workflow-state connection with cursor pagination |
iter_workflow_states(team_id, page_size=50, limit=None, include_archived=False, order_by=None) |
str, int, int | None, bool, PaginationOrderBy | None |
AsyncIterator[WorkflowState] |
Follows all pages automatically |
get_workflow_state_by_type(team_id, state_type, include_archived=False) |
str, str, bool |
WorkflowState |
Returns the unique state of that type; raises LinearWorkflowStateLookupError for zero or multiple matches |
Team workflow states
Workflow states are queried directly from Linear's workflowStates connection and
filtered by team ID. list_workflow_states returns only the first page; use the
iterator when you want every state:
async for state in queries.iter_workflow_states(team.id):
print(state.id, state.name, state.type)
For an operation such as completing an issue, resolve the state by type. This searches all pages and succeeds only when exactly one state matches:
done = await queries.get_workflow_state_by_type(team.id, "completed")
await mutations.update_issue(issue.id, IssueUpdateInput(state_id=done.id))
If zero or multiple states have that type, the method raises
LinearWorkflowStateLookupError; it does not guess. Archived states are excluded
by default. Pass include_archived=True to include them. A type string that does
not match any state of this team raises LinearWorkflowStateLookupError with
multiple=False. API failures propagate as LinearAPIError; a stalled state
connection propagates as LinearPaginationError.
Valid state types are triage, backlog, unstarted, started, completed,
and canceled. A team may have multiple states with the same type—especially
canceled states such as Canceled and Duplicate—so the lookup can raise for
ambiguity even when that type is valid.
Use the page method when you need explicit cursor metadata:
states = await queries.list_workflow_states_page(team.id, first=50)
for state in states.nodes:
print(state.id, state.name, state.type)
if states.page_info.has_next_page:
next_page = await queries.list_workflow_states_page(
team.id,
first=50,
after=states.page_info.end_cursor,
)
Team key → ID and filtered issue pages
Use get_team_by_key to resolve a human-facing team key, then query a cursor-aware
connection. The following finds open issues in that team, ordered by their latest update:
from gtm_linear import (
PaginationOrderBy,
)
team = await queries.get_team_by_key("ENG")
assert team is not None
issue_filter = {
"team": {"id": {"eq": team.id}},
"state": {"type": {"nin": ["completed", "canceled"]}},
}
page_size = 100
order_by = PaginationOrderBy.updatedAt
issues = await queries.list_issues_page(
issue_filter,
first=page_size,
order_by=order_by,
)
for issue in issues.nodes:
print(issue.identifier)
if issues.page_info.has_next_page:
next_page = await queries.list_issues_page(
issue_filter,
first=page_size,
after=issues.page_info.end_cursor,
order_by=order_by,
)
Issue shape returned by queries
get_issue and issue list/search methods return this projection:
Issue(
id: str,
title: str,
description: str | None,
identifier: str, # e.g. "ENG-123" — the human-readable ID
url: str,
priority: int | None, # 0=None, 1=Urgent, 2=High, 3=Medium, 4=Low (Linear convention)
status: str | None, # state.name flattened — e.g. "In Progress"
assignee: User | None,
)
status is a flattened string (the state's name), not the full Linear WorkflowState object. If you need state ID or color, use execute_async directly.
Issue comments, attachments, and relations
get_issue intentionally remains a lightweight projection. Use the issue-context
page methods when a caller needs associated records, or the iterators to retrieve
all pages without managing cursors. Each connection has an independent cursor;
incoming and outgoing relations are exposed separately. Archived records are
excluded by default and can be included with include_archived=True.
comments = await queries.list_issue_comments_page("ENG-123", first=50)
if comments.page_info.has_next_page:
next_comments = await queries.list_issue_comments_page(
"ENG-123",
first=50,
after=comments.page_info.end_cursor,
)
async for comment in queries.iter_issue_comments("ENG-123"):
if comment.user is not None:
author_name = comment.user.name
elif comment.external_user is not None:
author_name = comment.external_user.display_name
else:
author_name = None
print(comment.created_at, author_name, comment.body)
async for attachment in queries.iter_issue_attachments("ENG-123"):
print(attachment.title, attachment.url, attachment.source_type)
async for relation in queries.iter_issue_relations("ENG-123"):
print(relation.type, relation.related_issue.identifier)
async for relation in queries.iter_issue_inverse_relations("ENG-123"):
print(relation.type, relation.issue.identifier)
An issue that does not exist returns a valid empty context page. Empty connections also retain their page metadata, and malformed response shapes continue to raise Pydantic validation errors.
LinearMutations reference
| Method | Args | Returns | Raises |
|---|---|---|---|
create_issue(input_) |
IssueCreateInput |
Issue (full) |
ValueError if API returns no issue; LinearAPIError on transport failure |
update_issue(issue_id, update) |
str, IssueUpdateInput |
Issue (full) |
ValueError if API returns no issue; LinearAPIError on transport failure |
delete_issue(issue_id) |
str |
bool (success flag) |
LinearAPIError on transport failure |
create_comment(issue_id, body) |
str, str |
Comment (full) |
ValueError if API returns no comment; LinearAPIError on transport failure |
create_attachment(input_) |
AttachmentCreateInput |
Attachment (typed projection) |
Pydantic validation error for malformed response; LinearAPIError on API failure |
create_issue_relation(input_) |
IssueRelationCreateInput |
IssueRelation (both issue identifiers included) |
Pydantic validation error for malformed response; LinearAPIError on API failure |
Mutation pitfalls
IssueCreateInputandIssueUpdateInputomit fields set toNone; values such aspriority=0andlabelIds=[]are forwarded to Linear.delete_issuereturns Linear'ssuccessbool. AFalsereturn is not an exception — check it explicitly if you care.create_issueandupdate_issueraiseValueError, notLinearAPIError, when the API responds 200 but with an emptyissue. Catch both if you're wrapping.create_commentreturns a typedCommentwithcreatedAtparsed as a timezone-awaredatetimewhen Linear returns an ISO-8601 timestamp.create_attachmentreturns Linear's existing attachment when the same URL is already linked to that issue; Linear updates that attachment rather than creating a duplicate.- Generated attachment and relation inputs validate required IDs, URLs, and relation types before sending the request.
Linking external work and relating issues
Use create_attachment to link an external resource such as a GitHub pull request.
Linear identifies an attachment by its issue and URL, updating it if that URL is
already linked. Use create_issue_relation for supported relationships:
from gtm_linear import (
AttachmentCreateInput,
IssueRelationCreateInput,
IssueRelationType,
LinearMutations,
)
async def link_work(mutations: LinearMutations) -> None:
attachment = await mutations.create_attachment(
AttachmentCreateInput(
issue_id="ENG-123",
url="https://github.com/acme/service/pull/456",
title="Implement service change",
subtitle="Pull request #456",
),
)
relation = await mutations.create_issue_relation(
IssueRelationCreateInput(
issue_id="ENG-123",
related_issue_id="ENG-124",
type=IssueRelationType.blocks,
),
)
LinearWorkflow exposes the same methods as create_attachment_async /
create_attachment and create_issue_relation_async /
create_issue_relation for async and synchronous workflows.
Sync vs async
The low-level typed wrappers (LinearQueries, LinearMutations) are async-only.
For synchronous CLI code, prefer LinearWorkflow, which owns the client and runs
its async methods safely. Its synchronous methods must not be called from an active
event loop; use their *_async counterpart there.
To use the low-level wrappers from sync code, wrap them with asyncio.run:
import asyncio
from gtm_linear import LinearClient, LinearQueries
async def fetch() -> None:
async with LinearClient(api_key="...") as client:
return await LinearQueries(client).get_issue("iss-1")
issue = asyncio.run(fetch())
For sync-only use, drop down to LinearClient.execute(...) directly.
Error handling pattern
from gtm_linear import LinearAPIError, LinearClient
try:
async with LinearClient(api_key=key) as client:
data = await client.execute_async("query { viewer { id } }")
except LinearAPIError as exc:
# Both transport and GraphQL errors land here.
print(exc.message)
for err in exc.errors:
print(err.get("extensions", {}).get("code"), err.get("message"))
Common Linear error codes worth branching on (found in errors[].extensions.code):
AUTHENTICATION_ERROR— bad / missing API keyFORBIDDEN— key lacks scope for the operationINVALID_INPUT— malformed mutation inputRATELIMITED— back off and retry
The SDK does not retry on rate limits. Implement back-off at the call site.
Live smoke test
Read-only by default. Use to verify auth, network, and basic schema access:
LINEAR_API_KEY=lin_api_xxx uv run python scripts/smoke.py --team-key ENG
# add --create to also create+delete a throwaway issue
The script exercises: viewer query, get_team_by_key, get_team, list_issues_page, search_issues, and optionally create_issue + delete_issue. Source: scripts/smoke.py.
Repository layout
sdk-python-linear/
├── gtm_linear/
│ ├── __init__.py # public re-exports
│ ├── _generated/ # codegen output: Pydantic models + GraphQL documents
│ ├── _schema.py # generated Strawberry mirror ([strawberry] extra)
│ ├── cli.py # read-only CLI behind the gtm-linear command
│ ├── client.py # LinearClient (httpx transport)
│ ├── exceptions.py # LinearAPIError and friends
│ ├── models.py # LinearModel base class
│ ├── mutations.py # LinearMutations (async write helpers)
│ ├── pagination.py # cursor-following paginate helpers
│ ├── queries.py # LinearQueries (async read helpers)
│ ├── settings.py # LinearSettings (opt-in env/dotenv config)
│ └── workflow.py # LinearWorkflow (sync/async facade)
├── tests/ # respx-mocked suite (incl. test_cli.py)
├── scripts/ # codegen toolchain + smoke.py (live API)
├── operations/ # GraphQL selection sets (codegen inputs)
├── schema/ # Linear SDL pin (codegen input)
├── pyproject.toml # hatchling; deps, extras, console scripts
├── CHANGELOG.md # release notes
├── ruff.toml # lint config (repo-local, incl. per-file ignores)
├── pyrightconfig.json # pyright config (repo-local)
├── pyrefly.toml # type-checker config
├── .github/workflows/ # pypi.yml (publish), pullfrog.yml (agent harness)
├── .rwx/ # RWX pipeline — ci.yml (canonical CI) + dagger-ref-drift.yml (weekly fallback)
└── .trunk/ # lint config (trunk.io)
Build backend: hatchling. Wheel packages: ["gtm_linear"].
Development
uv sync # install deps
uv run pytest # run tests (respx-mocked, no network)
uv run pytest tests/test_client.py::test_execute_sync_returns_data # single test
uv run python scripts/gen_operations.py --check # operations/*.graphql vs _spec.toml
uv run python scripts/codegen.py --check # generated models vs pinned schema
trunk check --all # lint + type check (local; run uv sync first)
trunk fmt # autoformat
CI runs on RWX: .rwx/ci.yml runs pytest, the two --check scripts, a lean-install task (the wheel must import without the [strawberry] extra), and a weekly scheduled schema-drift cron (pytest -m network). .rwx/dagger-ref-drift.yml remains a weekly fallback that verifies the publisher tag/SHA pair and reports stale releases. GitHub Actions remains for the tag-triggered PyPI publish (pypi.yml) and the Pullfrog agent harness (pullfrog.yml) — see .rwx/.migration-inventory.md for the port inventory and flip status. Renovate updates the paired SemVer tag and immutable SHA in the PyPI workflow; its custom regex manager is intentionally the only enabled Renovate manager because Dependabot handles GitHub Actions. trunk check is not a CI gate.
Tests use respx to mock httpx — no network access required. pytest-asyncio is in auto mode, so async test functions don't need decoration.
Conventions
- All public methods are documented with Google-style docstrings.
- Never hand-edit
gtm_linear/_generated/orgtm_linear/_schema.py; regenerate withscripts/codegen.py(--checkfails CI on drift). Both are lint-ignored in.trunk/trunk.yaml, so fixes belong in the generator. - Transport and API failures raise the typed hierarchy in
gtm_linear/exceptions.py. - Input types are constructed positionally in tests and the smoke script; some checkers flag this (see
# pyright: ignore[reportCallIssue]inscripts/smoke.py). - Tests intentionally use
assertand_privateinternals; scoped per-file ignores inruff.tomlcover this, so don't add per-linenoqas. - No retries, no connection pooling tuning, no logging. Add at the call site.
Known gaps (read before extending)
- Filtering coverage: Issue filters are plain
dict[str, Any]mappings that mirror Linear's nested filter tree. Useexecute_asyncfor other Linear filters. - Schema coverage: Only a focused subset of Linear resources is typed. Attachments, cycles, projects-as-containers, workflow mutations, and webhooks remain absent.
- Search filtering:
search_issuesaccepts only a text term. Uselist_issues_pagefor mapped team/state filtering. - Subscriptions: Not supported. Linear's
subscriptionAPI requires WebSockets — the client is HTTP-only. - Status filtering: Workflow-state query results include the state ID and type. More advanced filters still require
execute_async.
License
MIT. See LICENSE.
Metadata
Release files for gtm-linear 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| gtm_linear-0.4.0.tar.gz | 338.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| gtm_linear-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 410.8 kB
Release files / gtm_linear-0.4.0.tar.gz
| Download URL | gtm_linear-0.4.0.tar.gz |
|---|---|
| Size | 338.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9329af89ee2d32c3177b7168770b18c907828cd940244c9d787b1badb681882a
|
|
BLAKE2b-256 checksum How to use checksums |
2a6e13f8a5243d94c04293c011df2636278182f4604fee4588116c9686cbef2c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency logRelease files / gtm_linear-0.4.0-py3-none-any.whl
| Download URL | gtm_linear-0.4.0-py3-none-any.whl |
|---|---|
| Size | 72.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
26f3b023e43a4bdeaed4ed811abd92b45812870eff3f8b2aad1a6735c77abd00
|
|
BLAKE2b-256 checksum How to use checksums |
f623805c9ddaf3004bfda4a63a31728d9b135c92916eb1c403bb9a814d15762f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency log