# linear-api
[](https://github.com/bipa-app/linear-api/actions/workflows/ci.yml)
[](https://crates.io/crates/linear-api)
[](https://docs.rs/linear-api)
Unofficial, typed async Rust client for the [Linear](https://linear.app) GraphQL API — not
affiliated with Linear Orbit, Inc.
Curated, handwritten operations (no codegen) with every GraphQL document schema-validated in CI
against Linear's committed SDL snapshot. Built for programmatic board automation: honest
rate-limit handling, mutation-safe retries, tri-state update inputs, and typed IDs.
## Installation
```sh
cargo add linear-api
```
## Authentication
**API keys only** (no OAuth). Keys look like `lin_api_…` and are created under
*Linear → Settings → Security & access → Personal API keys*. Linear expects the raw key in the
`Authorization` header — **no `Bearer` prefix** — and this crate sends it exactly that way,
stored as a [`secrecy`](https://docs.rs/secrecy) secret and marked sensitive on the wire.
```rust,no_run
# fn run() -> linear_api::Result<()> {
// Reads LINEAR_API_KEY from the environment:
let client = linear_api::LinearClient::from_env()?;
// Or configure explicitly:
let client = linear_api::LinearClient::builder()
.api_key("lin_api_...")
.timeout(std::time::Duration::from_secs(30))
.build()?;
# Ok(())
# }
```
## Quickstart
```rust,no_run
use linear_api::issues::{IssueCreateInput, ListIssuesRequest};
use linear_api::{IssueFilter, LinearClient, StringComparator, TeamFilter};
# async fn run() -> linear_api::Result<()> {
let client = LinearClient::from_env()?;
// Who am I?
let viewer = client.viewer().await?;
println!("logged in as {} <{}>", viewer.display_name, viewer.email);
// Resolve the ENG team.
let team = client.teams().get(&linear_api::TeamId::new("ENG")).await?;
// List its open issues (one page; see `list_stream` for lazy full drains).
let page = client
.issues()
.list(
ListIssuesRequest::builder()
.filter(
IssueFilter::builder()
.team(
TeamFilter::builder()
.key(StringComparator::builder().eq("ENG".to_string()).build())
.build(),
)
.build(),
)
.first(25)
.build(),
)
.await?;
for issue in &page.nodes {
println!("{} {} [{}]", issue.identifier, issue.title, issue.state.name);
}
// Create an issue.
let issue = client
.issues()
.create(
IssueCreateInput::builder()
.team_id(team.id.clone())
.title("Fix the flux capacitor")
.build(),
)
.await?;
// Comment on it, then link a blocker.
client.comments().create_on(&issue.id, "On it.").await?;
client
.relations()
.create_blocks(linear_api::IssueRef::identifier("ENG-1"), &issue.id)
.await?;
# Ok(())
# }
```
Update mutations use `Undefinable<T>` for tri-state semantics — *leave unchanged* (default),
*clear* (`Undefinable::Null`), or *set*:
```rust,no_run
use linear_api::Undefinable;
use linear_api::issues::IssueUpdateInput;
# async fn run(client: linear_api::LinearClient) -> linear_api::Result<()> {
client
.issues()
.update(
linear_api::IssueRef::identifier("ENG-123"),
IssueUpdateInput::builder()
.title("New title") // set
.assignee_id(Undefinable::Null) // clear (unassign)
.build(), // everything else: unchanged
)
.await?;
# Ok(())
# }
```
Runnable examples:
[`examples/viewer.rs`](https://github.com/bipa-app/linear-api/blob/master/examples/viewer.rs)
(auth + rate-limit snapshot) and
[`examples/workspace_tour.rs`](https://github.com/bipa-app/linear-api/blob/master/examples/workspace_tour.rs)
(a read-only tour of the typed surface). Both need `LINEAR_API_KEY`:
```sh
LINEAR_API_KEY=lin_api_... cargo run --example workspace_tour
```
## Coverage
| P0 | Viewer / workspace | `viewer`, `organization` | ✅ shipped |
| P0 | Teams | `teams().list / list_stream / get / states` | ✅ shipped |
| P0 | Users | `users().list / list_stream / get` | ✅ shipped |
| P0 | Workflow states | `workflow_states().list` | ✅ shipped |
| P0 | Issues | `get / list / list_stream / search / create / batch_create / update / archive / delete / add_label / remove_label` | ✅ shipped |
| P0 | Projects | `list / list_stream / get / create / update / archive / statuses` | ✅ shipped |
| P0 | Project milestones | `milestones / create_milestone / update_milestone / delete_milestone` | ✅ shipped |
| P0 | Labels | `list / list_stream / create / update / delete / ensure` (find-or-create) | ✅ shipped |
| P0 | Issue relations | `create / create_blocks / delete / of_issue` (blocks / blocked-by views) | ✅ shipped |
| P0 | Comments | `list_for_issue / list_for_issue_stream / create / create_on / update / delete` | ✅ shipped |
| P0 | Typed filters | `IssueFilter`, `ProjectFilter`, `TeamFilter`, `UserFilter`, `WorkflowStateFilter`, `IssueLabelFilter`, `CommentFilter`, … | ✅ shipped |
| P0 | Escape hatch | `execute_raw` (arbitrary documents, same error taxonomy) | ✅ shipped |
| P1 | Cycles | list/get, issue scheduling | 🔲 planned |
| P1 | Attachments | `attachmentCreate`, `attachmentLinkURL` | 🔲 planned |
| P1 | Project updates | status posts + health reporting | 🔲 planned |
| P1 | Batch issue update | `issueBatchUpdate` | 🔲 planned |
| P2 | Webhooks | payload types + HMAC-SHA256 verification helper | 🔲 roadmap |
| P2 | Documents / initiatives / customer needs | — | 🔲 roadmap |
| P2 | OAuth | — | 🔲 roadmap |
Deprecated operations (e.g. `issueSearch`) are never exposed; `searchIssues` is used instead.
Until a tier lands, `execute_raw` covers the long tail.
## Rate limits & retries
The client is **header-aware and mutation-safe**:
- Every response's budget headers (`X-RateLimit-Requests-*`, `X-Complexity`,
`X-RateLimit-Complexity-*`) are parsed into a snapshot readable via
`LinearClient::last_rate_limit()`. Budgets are never hardcoded — Linear's documented numbers
are inconsistent; the headers are the source of truth.
- Rate-limit rejections (HTTP 429, or Linear's HTTP **400** + `RATELIMITED` extension code) are
retried after the header-indicated wait — these are rejected *before execution*, so retrying
is always safe, mutations included. If the wait exceeds
`RetryConfig::max_rate_limit_wait` (default 30s) the call fails fast with
`Error::RateLimited { retry_after, .. }` instead of parking your task.
- **Queries** retry on transient failures (connect errors, post-send transport errors, 5xx)
with jittered exponential backoff (default 3 attempts, 250ms base, 8s cap).
- **Mutations** retry only on *connect* errors (nothing was sent). Linear has no idempotency
keys — a timed-out `issueCreate` may have landed, and a blind retry double-creates. Opt in
with `RetryConfig::retry_mutations_on_transient` if your caller dedupes.
- GraphQL errors are **fail-closed**: if `errors` is non-empty the call returns
`Error::Api { .. }` even when partial `data` was present (`execute_raw` is the lenient
escape hatch).
## Errors
One `Error` enum with honest variants: `Config`, `Transport`, `Http`, `Api` (typed via
`LinearErrorType`, e.g. `AuthenticationError` / `Forbidden` / `InvalidInput`), `RateLimited`
(with the server-indicated wait), `Decode` (the runtime schema-drift tripwire), `MissingData`,
and `MutationFailed` (`success: false` without errors). Helpers: `is_rate_limited()`,
`is_authentication()`, `error_types()`.
## Schema drift defense
Every GraphQL document ships in a compile-time registry and is validated against the committed
SDL snapshot (`schema/linear.graphql`) by `tests/schema_validation.rs` using `apollo-compiler` —
a required CI check. A nightly job diffs the live SDL against the snapshot, and env-gated live
smoke tests (`cargo test -- --ignored` with `LINEAR_API_KEY`; read-only) exercise the real API.
## Features
| `rustls-tls` | yes | TLS via rustls with webpki roots |
| `native-tls` | no | TLS via the platform's native stack |
| `tracing` | no | `tracing` events: `debug!` per request, `warn!` per retry |
## MSRV
Rust 1.85 (edition 2024). Checked in CI.
## License
Licensed under either of
[MIT](https://github.com/bipa-app/linear-api/blob/master/LICENSE-MIT) or
[Apache-2.0](https://github.com/bipa-app/linear-api/blob/master/LICENSE-APACHE), at your option.