Skip to content

feat(tracker): Linear adapter#43

Open
harshitsinghbhandari wants to merge 2 commits into
mainfrom
feat/tracker-linear
Open

feat(tracker): Linear adapter#43
harshitsinghbhandari wants to merge 2 commits into
mainfrom
feat/tracker-linear

Conversation

@harshitsinghbhandari
Copy link
Copy Markdown
Collaborator

Summary

Read-only Linear adapter for the existing ports.Tracker interface — Get, List, Preflight. Mirrors the github adapter file layout (tracker.go, auth.go, doc.go, tracker_test.go), shares the same sentinel error surface (ErrNotFound, ErrAuthFailed, ErrRateLimited, ErrWrongProvider, ErrBadID, ErrNoToken), the same typed RateLimitError{ResetAt, RetryAfter}, and the same atomic.Bool + sync.Mutex preflight caching.

Do NOT merge yet — waiting on PR #40 (Comment/Transition) to ensure the write side is shaped consistently before this lands.

Why hand-rolled GraphQL (no SDK)

  • @linear/sdk is a TypeScript artifact; equivalent Go ports ship a ~700KB+ generated documents file we'd touch ~3 endpoints of.
  • Tests can drive the wire directly via httptest that inspects {query, variables} — no SDK shim required.
  • Error classification stays in one place (extensions.type → sentinel) with the same contract surface as the github adapter.

Auth header — NO Bearer prefix

Linear personal API keys are sent raw:

Authorization: lin_api_xxxxxxxxxxxx

OAuth tokens DO use Bearer, but v1 only supports personal keys. This is the single easiest bug to introduce — TestAuthHeader_NoBearerPrefix pins it.

State mapping (Linear state.typeNormalizedIssueState)

Linear state.type Normalized
completed done
canceled cancelled
started in_progress
unstarted open
triage open
backlog open
(any other / empty) open

review is intentionally not produced. Linear has no native review type; teams using "In Review" set type=started, which collapses to in_progress. Name-based mapping is brittle across customized workflows — see doc.go for the full rationale.

Errors

Linear surfaces failures via errors[].extensions.type (lowercase strings, e.g. "authentication error", "ratelimited", "feature not accessible", "forbidden") — NOT extensions.code with SCREAMING_SNAKE_CASE. This was the one place the implementation diverged from the task prompt; the authoritative source is @linear/sdk's error.ts at HEAD (lowercase strings) and the adapter codes to that reality. doc.go calls this out explicitly.

Classification priority:

  1. Recognized extensions.type → sentinel (handles 200 + ratelimited / authentication error)
  2. HTTP status 401/403/429 → ErrAuthFailed / ErrRateLimited
  3. Unknown errors[] on a 2xx → generic graphql error with original message

Type normalization is case-insensitive (strings.ToLower(TrimSpace(...))) to defend against upstream casing drift.

Links

Test plan

  • go test ./... -race -count=1 (343 tests, 15 packages, all green)
  • go vet ./... clean
  • gofmt -l backend/internal/adapters/tracker/linear/ clean
  • Tests use httptest.NewServer routing by parsed {query, variables} body — no mocks of t.do
  • Self-reviewed with superpowers:code-reviewer; addressed all strong-recommends (team-cache mutex no longer held across network IO; case-insensitive extensions.type matching; Preflight token-rotation behavior documented)

🤖 Generated with Claude Code

harshitsinghbhandari and others added 2 commits May 31, 2026 00:00
Read-only v1 adapter against Linear's GraphQL API, mirroring the
github adapter's file layout, sentinel errors, RateLimitError shape,
and atomic.Bool+sync.Mutex preflight caching. Hand-rolled GraphQL
over net/http — no Linear SDK dependency.

Issue identity: TrackerID.Native is opaque (short id or UUID) and
passes straight to issue(id:). TrackerRepo.Native is the team key;
List resolves it lazily to a team UUID via teams(filter:{key:{eq}})
and caches the mapping. Empty Native means workspace-wide list.

Authorization header is sent raw — no Bearer prefix — because v1
only supports personal API keys.

Errors are classified via errors[].extensions.type (Linear's
lowercase-words discriminator) with HTTP status as fallback;
ratelimited surfaces RateLimitError with RetryAfter and ResetAt
parsed from Retry-After / X-RateLimit-Requests-Reset.

State map: completed→done, canceled→cancelled, started→in_progress,
unstarted/triage/backlog/unknown→open. NO `review` in v1 — doc.go
explains why.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Linear has no CLI keyring like gh, so a personal API key in an env
var is the only path. Make the failure mode self-fixing:

  * ErrNoToken's message names both the LINEAR_API_KEY env var and
    https://linear.app/settings/api, so a fresh dev hitting it sees
    the fix in the error itself.
  * EnvTokenSource wraps the sentinel with the deduped list of env
    vars it actually consulted, so multi-env setups (e.g. project
    overrides) surface exactly which names matter.
  * doc.go gains a Getting started section pointing at the same URL.

errors.Is(err, ErrNoToken) still routes — SM matching is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant