linear-api 0.1.0

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

linear-api

CI crates.io docs.rs

Unofficial, typed async Rust client for the Linear 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

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 secret and marked sensitive on the wire.

# 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

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:

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 (auth + rate-limit snapshot) and examples/workspace_tour.rs (a read-only tour of the typed surface). Both need LINEAR_API_KEY:

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 or Apache-2.0, at your option.