linear-api 0.1.0

Unofficial async Rust client for the Linear GraphQL API (API-key auth)
Documentation
# linear-api

[![CI](https://github.com/bipa-app/linear-api/actions/workflows/ci.yml/badge.svg)](https://github.com/bipa-app/linear-api/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/linear-api.svg)](https://crates.io/crates/linear-api)
[![docs.rs](https://img.shields.io/docsrs/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

| Tier | Area | Operations | Status |
|------|------|------------|--------|
| 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

| Feature      | Default | Description                                              |
|--------------|---------|----------------------------------------------------------|
| `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.