From f97cde0980ccc30f3bcc0a5aeca788268b0ef688 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 31 May 2026 00:00:57 +0530 Subject: [PATCH 1/2] feat(tracker): Linear adapter (Get, List, Preflight) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/adapters/tracker/linear/auth.go | 50 ++ .../internal/adapters/tracker/linear/doc.go | 103 +++ .../adapters/tracker/linear/tracker.go | 558 ++++++++++++ .../adapters/tracker/linear/tracker_test.go | 831 ++++++++++++++++++ 4 files changed, 1542 insertions(+) create mode 100644 backend/internal/adapters/tracker/linear/auth.go create mode 100644 backend/internal/adapters/tracker/linear/doc.go create mode 100644 backend/internal/adapters/tracker/linear/tracker.go create mode 100644 backend/internal/adapters/tracker/linear/tracker_test.go diff --git a/backend/internal/adapters/tracker/linear/auth.go b/backend/internal/adapters/tracker/linear/auth.go new file mode 100644 index 00000000..d2a66153 --- /dev/null +++ b/backend/internal/adapters/tracker/linear/auth.go @@ -0,0 +1,50 @@ +package linear + +import ( + "context" + "errors" + "os" + "strings" +) + +// TokenSource yields a Linear personal API key on demand. Mirrors the +// GitHub adapter's TokenSource so the Session Manager only needs to know +// one shape across providers. The Tracker calls Token once at construction +// (fail-fast) and again per request so rotated tokens are picked up +// without restart. +type TokenSource interface { + Token(ctx context.Context) (string, error) +} + +// ErrNoToken is returned when no token source could yield a non-empty token. +var ErrNoToken = errors.New("linear tracker: no token configured") + +// StaticTokenSource is a literal token, typically used in tests. +type StaticTokenSource string + +func (s StaticTokenSource) Token(context.Context) (string, error) { + t := strings.TrimSpace(string(s)) + if t == "" { + return "", ErrNoToken + } + return t, nil +} + +// EnvTokenSource reads the first non-empty value from the listed env vars, +// falling back to LINEAR_API_KEY. The order matters: a project-configured +// token (e.g. AO_LINEAR_TOKEN) should be preferred over the global default. +type EnvTokenSource struct { + EnvVars []string +} + +func (s EnvTokenSource) Token(context.Context) (string, error) { + for _, name := range s.EnvVars { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v, nil + } + } + if v := strings.TrimSpace(os.Getenv("LINEAR_API_KEY")); v != "" { + return v, nil + } + return "", ErrNoToken +} diff --git a/backend/internal/adapters/tracker/linear/doc.go b/backend/internal/adapters/tracker/linear/doc.go new file mode 100644 index 00000000..76d69a5e --- /dev/null +++ b/backend/internal/adapters/tracker/linear/doc.go @@ -0,0 +1,103 @@ +// Package linear implements the ports.Tracker outbound port for Linear. +// v1 is read-only: +// +// - Get returns a normalized snapshot of one issue. TrackerID.Native is +// the opaque identifier Linear accepts at issue(id:) — either the +// team-prefixed short id ("ABC-123") or the issue's UUID. The adapter +// does NOT parse Native; it is passed straight through. +// - List returns one page of issues. TrackerRepo.Native is the team key +// (e.g. "ABC") or empty for a workspace-wide enumeration. When a team +// key is set, the adapter lazily resolves it to the team UUID via +// teams(filter:{key:{eq:$key}}, first:1) and caches the result so +// subsequent calls for the same team skip the lookup. +// - Preflight performs a single { viewer { id } } query against Linear +// to verify the token is accepted; success is cached for the lifetime +// of the Tracker, failures are not. +// +// Writing back to the tracker (Comment, Transition) is deferred to issue +// #40. The observer / polling loop is deferred to #35. +// +// # Authentication +// +// Linear personal API keys are sent as a RAW Authorization header value +// with NO "Bearer " prefix: +// +// Authorization: lin_api_xxxxxxxxxxxx +// +// This is the single most common source of 401s on this adapter — OAuth +// tokens DO use Bearer, but personal keys do NOT. v1 only supports +// personal keys, so the adapter never prefixes Bearer. +// +// Token rotation: the TokenSource is consulted on EVERY request, so a +// rotated token is picked up without restarting. Preflight, however, +// caches the FIRST successful validation for the lifetime of the +// Tracker — if a previously-valid token is later revoked, Preflight will +// continue to return nil, and the bad-token signal will surface lazily +// on the next Get/List (as ErrAuthFailed via 401 or extensions.type). +// Daemons that need to react to revocation must rely on per-request +// failures, not periodic Preflight. +// +// # Transport +// +// The adapter hand-rolls GraphQL over net/http. We intentionally do NOT +// depend on the Linear SDK or any Go GraphQL client library: +// +// - The SDK ships a 700KB+ generated documents file and a huge surface +// we'd touch ~3 endpoints of. v1 stays small and auditable. +// - Tests can drive the wire exactly via an httptest server that +// inspects {query, variables}; no SDK shimming required. +// - Errors are routed through one classifier (extensions.type → +// sentinel), keeping the adapter's contract with the SM identical to +// the github adapter. +// +// # Reverse state mapping +// +// Linear's workflow state.type vocabulary is fixed: +// +// triage, backlog, unstarted, started, completed, canceled +// +// Get projects them onto the normalized state as follows: +// +// completed -> done +// canceled -> cancelled +// started -> in_progress +// unstarted | triage | backlog | "" -> open +// (any other value) -> open +// +// Note: NormalizedIssueState.review is intentionally NOT produced by this +// adapter in v1. Linear has no native "review" type — teams that use a +// status named "In Review" still set type=started, which we collapse to +// in_progress. A v2 could distinguish via state.name string match, but +// every Linear workspace customizes its workflow so name-based mapping is +// brittle. We surface in_progress and rely on label filtering at the +// caller side when finer state is needed. +// +// # Errors +// +// Linear surfaces errors in two shapes that the adapter normalizes to the +// same sentinels: +// +// - HTTP 200 with a JSON errors[] array. Each error carries +// extensions.type — Linear's lowercase-words discriminator (e.g. +// "authentication error", "ratelimited", "feature not accessible"). +// This is the common case; even rate-limited mutations frequently +// come back as 200 + ratelimited rather than 429. +// - HTTP 401 / 429 / 5xx with errors[] but no successful data. The +// classifier checks errors[].extensions.type first and falls back to +// status code so either surface routes to the same sentinel. +// +// The wire field is extensions.type (lowercase strings with spaces) — +// NOT extensions.code with SCREAMING_SNAKE_CASE. This is consistent with +// the official @linear/sdk error.ts at HEAD. +// +// # Out of scope +// +// - No Comment, no Transition (issue #40). +// - No List auto-pagination — callers get one page bounded by +// ListFilter.Limit (default 50, silently capped at Linear's first: +// hard limit of 250). Observer/polling work lands in issue #35. +// - No webhook receiver, no polling goroutine. +// - No complexity-aware client-side throttling. RateLimitError carries +// RetryAfter / ResetAt so the SM can back off, but we don't model +// the X-RateLimit-Complexity-* headers as a separate budget in v1. +package linear diff --git a/backend/internal/adapters/tracker/linear/tracker.go b/backend/internal/adapters/tracker/linear/tracker.go new file mode 100644 index 00000000..3197a070 --- /dev/null +++ b/backend/internal/adapters/tracker/linear/tracker.go @@ -0,0 +1,558 @@ +package linear + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" + "github.com/aoagents/agent-orchestrator/backend/internal/ports" +) + +const ( + // BaseURL is the host root; the adapter appends graphqlPath for every + // request. Treating BaseURL this way mirrors the github adapter (where + // BaseURL is "https://api.github.com" and routes are joined onto it) + // so tests can point at an httptest server URL with no special-casing. + defaultBaseURL = "https://api.linear.app" + graphqlPath = "/graphql" + defaultUserAgent = "ao-agent-orchestrator/tracker-linear" + + // Linear's pagination default and hard cap on the first: argument. + defaultListLimit = 50 + maxListLimit = 250 + + // extensions.type values Linear sets on GraphQL errors. Spelled + // lowercase with spaces, matching @linear/sdk/error.ts at HEAD. + extTypeAuthError = "authentication error" + extTypeRatelimited = "ratelimited" + extTypeFeatureGated = "feature not accessible" + extTypeForbidden = "forbidden" +) + +// Sentinel errors. Same shape as the github adapter so the SM can match on +// errors.Is across providers without per-adapter knowledge. +var ( + ErrNotFound = errors.New("linear tracker: issue not found") + ErrRateLimited = errors.New("linear tracker: rate limited") + ErrAuthFailed = errors.New("linear tracker: authentication failed") + ErrWrongProvider = errors.New("linear tracker: id is not a linear tracker id") + ErrBadID = errors.New("linear tracker: malformed native id") +) + +// RateLimitError is returned when Linear reports the request was +// rate-limited. Callers wanting to back off can pull ResetAt / RetryAfter +// via errors.As; callers that only need the category can use +// errors.Is(err, ErrRateLimited). +type RateLimitError struct { + ResetAt time.Time + RetryAfter time.Duration + Message string +} + +func (e *RateLimitError) Error() string { + if e == nil { + return ErrRateLimited.Error() + } + if e.Message != "" { + return "linear tracker: rate limited: " + e.Message + } + return ErrRateLimited.Error() +} + +func (e *RateLimitError) Is(target error) bool { return target == ErrRateLimited } + +// Options configures a Tracker. Token is the only required field in +// production; tests inject HTTPClient and BaseURL to point at httptest. +type Options struct { + Token TokenSource + HTTPClient *http.Client + BaseURL string + UserAgent string +} + +// Tracker implements ports.Tracker against Linear's GraphQL API. +// +// Construction performs a fail-fast token presence check (no network +// call). Preflight verifies the token against Linear itself; success is +// cached for the lifetime of the Tracker, failures are not. +// +// The team-key → UUID resolution required by List(team-scoped) is cached +// in teamUUIDs guarded by teamMu so concurrent first-callers serialize on +// the lookup and subsequent calls skip the network entirely. +type Tracker struct { + http *http.Client + tokens TokenSource + baseURL string + userAgent string + + preflightOK atomic.Bool + preflightMu sync.Mutex + + teamMu sync.Mutex + teamUUIDs map[string]string +} + +// New returns a Tracker. Fails fast when no token can be obtained. +func New(opts Options) (*Tracker, error) { + src := opts.Token + if src == nil { + return nil, ErrNoToken + } + if _, err := src.Token(context.Background()); err != nil { + return nil, err + } + t := &Tracker{ + http: opts.HTTPClient, + tokens: src, + baseURL: opts.BaseURL, + userAgent: opts.UserAgent, + teamUUIDs: make(map[string]string), + } + if t.http == nil { + t.http = &http.Client{Timeout: 30 * time.Second} + } + if t.baseURL == "" { + t.baseURL = defaultBaseURL + } + if t.userAgent == "" { + t.userAgent = defaultUserAgent + } + return t, nil +} + +var _ ports.Tracker = (*Tracker)(nil) + +// --------------------------------------------------------------------------- +// Get +// --------------------------------------------------------------------------- + +const issueQuery = `query Issue($id: String!) { + issue(id: $id) { + identifier + title + description + url + state { type } + labels { nodes { name } } + assignees { nodes { name } } + } +}` + +type linearIssue struct { + Identifier string `json:"identifier"` + Title string `json:"title"` + Description string `json:"description"` + URL string `json:"url"` + State struct { + Type string `json:"type"` + } `json:"state"` + Labels struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` + } `json:"labels"` + Assignees struct { + Nodes []struct { + Name string `json:"name"` + } `json:"nodes"` + } `json:"assignees"` +} + +func (t *Tracker) Get(ctx context.Context, id domain.TrackerID) (domain.Issue, error) { + if id.Provider != domain.TrackerProviderLinear { + return domain.Issue{}, fmt.Errorf("%w: provider=%q", ErrWrongProvider, id.Provider) + } + if strings.TrimSpace(id.Native) == "" { + return domain.Issue{}, fmt.Errorf("%w: empty native id", ErrBadID) + } + + var data struct { + Issue *linearIssue `json:"issue"` + } + if err := t.do(ctx, issueQuery, map[string]any{"id": id.Native}, &data); err != nil { + return domain.Issue{}, err + } + if data.Issue == nil { + return domain.Issue{}, fmt.Errorf("%w: %s", ErrNotFound, id.Native) + } + return issueFromLinear(id.Native, data.Issue), nil +} + +// issueFromLinear projects a Linear issue payload into the normalized +// domain.Issue. The caller's original Native is echoed on the returned ID +// so a UUID-style lookup round-trips faithfully — we don't substitute the +// short identifier from Linear's response. +func issueFromLinear(native string, raw *linearIssue) domain.Issue { + out := domain.Issue{ + ID: domain.TrackerID{ + Provider: domain.TrackerProviderLinear, + Native: native, + }, + Title: raw.Title, + Body: raw.Description, + State: mapStateFromLinear(raw.State.Type), + URL: raw.URL, + } + if n := len(raw.Labels.Nodes); n > 0 { + out.Labels = make([]string, n) + for i, l := range raw.Labels.Nodes { + out.Labels[i] = l.Name + } + } + if n := len(raw.Assignees.Nodes); n > 0 { + out.Assignees = make([]string, n) + for i, a := range raw.Assignees.Nodes { + out.Assignees[i] = a.Name + } + } + return out +} + +func mapStateFromLinear(linearType string) domain.NormalizedIssueState { + switch linearType { + case "completed": + return domain.IssueDone + case "canceled": + return domain.IssueCancelled + case "started": + return domain.IssueInProgress + default: + return domain.IssueOpen + } +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +const teamLookupQuery = `query TeamByKey($key: String!) { + teams(filter: {key: {eq: $key}}, first: 1) { + nodes { id key } + } +}` + +const issuesQuery = `query Issues($filter: IssueFilter, $first: Int!) { + issues(filter: $filter, first: $first) { + nodes { + identifier + title + description + url + state { type } + labels { nodes { name } } + assignees { nodes { name } } + } + } +}` + +func (t *Tracker) List(ctx context.Context, repo domain.TrackerRepo, filter domain.ListFilter) ([]domain.Issue, error) { + if repo.Provider != domain.TrackerProviderLinear { + return nil, fmt.Errorf("%w: provider=%q", ErrWrongProvider, repo.Provider) + } + + filt := map[string]any{} + + // Team scoping: empty Native is workspace-wide; otherwise resolve the + // team key to a UUID (lazily, with caching) and add it to the filter. + if key := strings.TrimSpace(repo.Native); key != "" { + uuid, err := t.resolveTeamUUID(ctx, key) + if err != nil { + return nil, err + } + filt["team"] = map[string]any{"id": map[string]any{"eq": uuid}} + } + + switch filter.State { + case domain.ListOpen: + filt["state"] = map[string]any{"type": map[string]any{ + "in": []string{"unstarted", "started", "triage", "backlog"}, + }} + case domain.ListClosed: + filt["state"] = map[string]any{"type": map[string]any{ + "in": []string{"completed", "canceled"}, + }} + } + if filter.Assignee != "" { + filt["assignee"] = map[string]any{"name": map[string]any{"eq": filter.Assignee}} + } + if len(filter.Labels) > 0 { + filt["labels"] = map[string]any{"name": map[string]any{"in": filter.Labels}} + } + + limit := filter.Limit + if limit <= 0 { + limit = defaultListLimit + } + if limit > maxListLimit { + limit = maxListLimit + } + + vars := map[string]any{"first": limit} + if len(filt) > 0 { + vars["filter"] = filt + } + + var data struct { + Issues struct { + Nodes []linearIssue `json:"nodes"` + } `json:"issues"` + } + if err := t.do(ctx, issuesQuery, vars, &data); err != nil { + return nil, err + } + out := make([]domain.Issue, 0, len(data.Issues.Nodes)) + for i := range data.Issues.Nodes { + // Echo back Linear's identifier as Native — this is the list + // case, where the caller has no pre-existing string to preserve. + raw := &data.Issues.Nodes[i] + out = append(out, issueFromLinear(raw.Identifier, raw)) + } + return out, nil +} + +// resolveTeamUUID maps a team key (e.g. "ABC") to its UUID, caching the +// result. The cache is per-Tracker and grows monotonically — team keys +// don't churn, so this is fine in practice. +// +// Concurrency: the mutex is released across the network round-trip, so +// concurrent first-callers for DIFFERENT keys make their teams() lookups +// in parallel. Concurrent first-callers for the SAME key may both make +// the request — the result is idempotent and the second insert is a +// no-op overwrite. This is the simple-and-correct shape; bringing in +// per-key singleflight wasn't justified by v1's call volume. +func (t *Tracker) resolveTeamUUID(ctx context.Context, key string) (string, error) { + t.teamMu.Lock() + if uuid, ok := t.teamUUIDs[key]; ok { + t.teamMu.Unlock() + return uuid, nil + } + t.teamMu.Unlock() + + var data struct { + Teams struct { + Nodes []struct { + ID string `json:"id"` + Key string `json:"key"` + } `json:"nodes"` + } `json:"teams"` + } + if err := t.do(ctx, teamLookupQuery, map[string]any{"key": key}, &data); err != nil { + return "", err + } + if len(data.Teams.Nodes) == 0 { + return "", fmt.Errorf("%w: team key=%q", ErrNotFound, key) + } + uuid := data.Teams.Nodes[0].ID + + t.teamMu.Lock() + t.teamUUIDs[key] = uuid + t.teamMu.Unlock() + return uuid, nil +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +const viewerQuery = `query { viewer { id } }` + +// Preflight verifies the configured token is currently accepted by Linear +// (one viewer query). It does NOT prove the token has access to any +// specific workspace, team, or issue — those may still fail with +// ErrAuthFailed even after a successful Preflight. Per-resource auth is +// detected lazily at the first Get/List against the resource. +// +// Caching mirrors the github adapter: atomic.Bool fast path, sync.Mutex +// serializes the one-time network call, failures are NOT cached. +func (t *Tracker) Preflight(ctx context.Context) error { + if t.preflightOK.Load() { + return nil + } + t.preflightMu.Lock() + defer t.preflightMu.Unlock() + if t.preflightOK.Load() { + return nil + } + var data struct { + Viewer struct { + ID string `json:"id"` + } `json:"viewer"` + } + if err := t.do(ctx, viewerQuery, nil, &data); err != nil { + return err + } + t.preflightOK.Store(true) + return nil +} + +// --------------------------------------------------------------------------- +// GraphQL plumbing +// --------------------------------------------------------------------------- + +// gqlResponse is the standard GraphQL envelope. data is left as RawMessage +// so do() can decode it into the caller's typed struct only after +// confirming there are no errors worth surfacing. +type gqlResponse struct { + Data json.RawMessage `json:"data"` + Errors []gqlError `json:"errors"` +} + +type gqlError struct { + Message string `json:"message"` + Path []any `json:"path,omitempty"` + Extensions map[string]any `json:"extensions,omitempty"` +} + +// do posts a GraphQL request and decodes data into out. It maps Linear's +// error surface — both HTTP-level and errors[].extensions.type — onto the +// adapter's sentinels. out may be nil for queries whose data the caller +// doesn't need (Preflight uses this). +func (t *Tracker) do(ctx context.Context, query string, variables map[string]any, out any) error { + body := map[string]any{"query": query} + if variables != nil { + body["variables"] = variables + } + buf, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("linear tracker: encode body: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, t.baseURL+graphqlPath, bytes.NewReader(buf)) + if err != nil { + return fmt.Errorf("linear tracker: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", t.userAgent) + + tok, err := t.tokens.Token(ctx) + if err != nil { + return err + } + // CRITICAL: NO "Bearer " prefix. Personal API keys go raw. See doc.go. + req.Header.Set("Authorization", tok) + + resp, err := t.http.Do(req) + if err != nil { + return fmt.Errorf("linear tracker: POST %s%s: %w", t.baseURL, graphqlPath, err) + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + + var env gqlResponse + // A non-JSON body on a non-2xx is still meaningful — fall back to + // HTTP-status classification when we can't parse the envelope. + jsonOK := len(respBody) > 0 && json.Unmarshal(respBody, &env) == nil + + // Priority: recognized extensions.type wins, because Linear surfaces + // the most actionable category that way (e.g. 200 + ratelimited). + // Failing that, HTTP status code carries the next-most-specific + // signal (401/429/5xx). A leftover errors[] with no recognized type + // on an HTTP-200 response surfaces a generic graphql error. + if jsonOK && len(env.Errors) > 0 { + if err := classifyKnownGraphQLError(resp, env.Errors); err != nil { + return err + } + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return classifyHTTPStatus(resp, respBody) + } + + if jsonOK && len(env.Errors) > 0 { + first := env.Errors[0] + if first.Message != "" { + return fmt.Errorf("linear tracker: graphql error: %s", first.Message) + } + return fmt.Errorf("linear tracker: graphql error with no message") + } + + if out == nil { + return nil + } + if !jsonOK { + return fmt.Errorf("linear tracker: decode envelope: invalid JSON body") + } + if len(env.Data) == 0 || string(env.Data) == "null" { + return fmt.Errorf("linear tracker: empty data field on success response") + } + if err := json.Unmarshal(env.Data, out); err != nil { + return fmt.Errorf("linear tracker: decode data: %w", err) + } + return nil +} + +// classifyKnownGraphQLError walks errors[] and returns a sentinel-wrapped +// error iff at least one entry carries a recognized extensions.type. A nil +// return means "no known type found" — the caller falls back to HTTP +// status code, then to a generic graphql-error wrap as a last resort. +// +// The type string is normalized via TrimSpace+ToLower before matching. +// Linear's @linear/sdk codes are documented as lowercase ("authentication +// error", "ratelimited", etc.), but the cost of normalizing is zero and +// the cost of a silent miscategorization (e.g. ErrAuthFailed → generic +// graphql-error → SM doesn't recover) is high. +func classifyKnownGraphQLError(resp *http.Response, errs []gqlError) error { + for _, e := range errs { + raw, _ := e.Extensions["type"].(string) + typ := strings.ToLower(strings.TrimSpace(raw)) + msg := e.Message + if msg == "" { + if upm, ok := e.Extensions["userPresentableMessage"].(string); ok { + msg = upm + } else { + msg = raw + } + } + switch typ { + case extTypeAuthError: + return fmt.Errorf("%w: %s", ErrAuthFailed, msg) + case extTypeRatelimited: + return rateLimited(resp, msg) + case extTypeFeatureGated, extTypeForbidden: + // Both mean "this token can't satisfy this request" — fold + // onto ErrAuthFailed so the SM's recovery path is uniform. + return fmt.Errorf("%w: %s", ErrAuthFailed, msg) + } + } + return nil +} + +// classifyHTTPStatus handles the non-2xx fallback for cases where Linear +// returns a status code without a parsable errors[] envelope (proxy +// errors, edge 429s, etc.). +func classifyHTTPStatus(resp *http.Response, body []byte) error { + msg := strings.TrimSpace(string(body)) + switch resp.StatusCode { + case http.StatusUnauthorized, http.StatusForbidden: + return fmt.Errorf("%w: %s", ErrAuthFailed, msg) + case http.StatusTooManyRequests: + return rateLimited(resp, msg) + } + return fmt.Errorf("linear tracker: %d %s", resp.StatusCode, msg) +} + +func rateLimited(resp *http.Response, msg string) error { + e := &RateLimitError{Message: msg} + if reset := resp.Header.Get("X-RateLimit-Requests-Reset"); reset != "" { + if sec, err := strconv.ParseInt(reset, 10, 64); err == nil && sec > 0 { + e.ResetAt = time.Unix(sec, 0) + } + } + if ra := resp.Header.Get("Retry-After"); ra != "" { + if sec, err := strconv.Atoi(ra); err == nil && sec >= 0 { + e.RetryAfter = time.Duration(sec) * time.Second + } + } + return e +} diff --git a/backend/internal/adapters/tracker/linear/tracker_test.go b/backend/internal/adapters/tracker/linear/tracker_test.go new file mode 100644 index 00000000..3dffea02 --- /dev/null +++ b/backend/internal/adapters/tracker/linear/tracker_test.go @@ -0,0 +1,831 @@ +package linear + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/aoagents/agent-orchestrator/backend/internal/domain" +) + +// recordedReq captures one inbound GraphQL POST so tests can assert against +// the exact query and variables the adapter sent. +type recordedReq struct { + Query string + Variables map[string]any +} + +// graphqlBody is the wire shape of every request the adapter sends to +// Linear: standard {query, variables} envelope. Tests use it to route by +// inspecting which top-level field the query references. +type graphqlBody struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` +} + +// fakeLinear is a programmable httptest.Server that always answers POST +// /graphql. It routes requests via a user-supplied router that inspects the +// parsed body. Unrouted requests fail the test loudly — same loud-failure +// discipline as the github adapter's tests. +type fakeLinear struct { + t *testing.T + server *httptest.Server + mu sync.Mutex + requests []recordedReq + router func(t *testing.T, w http.ResponseWriter, body graphqlBody) + + lastAuthHeader string +} + +func newFakeLinear(t *testing.T, router func(t *testing.T, w http.ResponseWriter, body graphqlBody)) *fakeLinear { + t.Helper() + f := &fakeLinear{t: t, router: router} + f.server = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.server.Close) + return f +} + +func (f *fakeLinear) serve(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + f.t.Errorf("unexpected method: %s", r.Method) + http.Error(w, "POST only", http.StatusMethodNotAllowed) + return + } + if r.URL.Path != "/graphql" { + f.t.Errorf("unexpected path: %s", r.URL.Path) + http.Error(w, "wrong path", http.StatusNotFound) + return + } + raw, _ := io.ReadAll(r.Body) + var body graphqlBody + if err := json.Unmarshal(raw, &body); err != nil { + f.t.Errorf("decode body: %v; raw=%s", err, raw) + http.Error(w, "bad body", http.StatusBadRequest) + return + } + f.mu.Lock() + f.requests = append(f.requests, recordedReq{Query: body.Query, Variables: body.Variables}) + f.lastAuthHeader = r.Header.Get("Authorization") + f.mu.Unlock() + f.router(f.t, w, body) +} + +func (f *fakeLinear) calls() []recordedReq { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]recordedReq, len(f.requests)) + copy(out, f.requests) + return out +} + +func (f *fakeLinear) authHeader() string { + f.mu.Lock() + defer f.mu.Unlock() + return f.lastAuthHeader +} + +// newTrackerForTest points an adapter at the fake server with a static +// token. Production code uses EnvTokenSource; tests skip that to keep the +// surface tiny. +func newTrackerForTest(t *testing.T, f *fakeLinear) *Tracker { + t.Helper() + tr, err := New(Options{ + BaseURL: f.server.URL, + Token: StaticTokenSource("lin_api_test"), + HTTPClient: f.server.Client(), + }) + if err != nil { + t.Fatalf("New: %v", err) + } + return tr +} + +func ctx() context.Context { return context.Background() } + +// writeJSON writes a JSON body with the given HTTP status. Tests use it as +// the single place that sets Content-Type so they stay focused on payload +// shape. +func writeJSON(w http.ResponseWriter, status int, body any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(body) +} + +func TestNewRejectsMissingToken(t *testing.T) { + if _, err := New(Options{Token: StaticTokenSource("")}); !errors.Is(err, ErrNoToken) { + t.Fatalf("New empty token = %v, want ErrNoToken", err) + } + if _, err := New(Options{}); !errors.Is(err, ErrNoToken) { + t.Fatalf("New no source = %v, want ErrNoToken", err) + } +} + +// TestAuthHeader_NoBearerPrefix pins the single easiest bug to introduce on +// this adapter: Linear personal API keys are sent as raw "Authorization: +// ", NOT "Authorization: Bearer ". OAuth tokens DO use Bearer but +// v1 only supports personal keys, so an accidental "Bearer "+tok would +// always 401. This guard catches the regression on every test that exercises +// the wire format. +func TestAuthHeader_NoBearerPrefix(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"viewer": map[string]any{"id": "u1"}}}) + }) + tr := newTrackerForTest(t, f) + if err := tr.Preflight(ctx()); err != nil { + t.Fatalf("Preflight: %v", err) + } + if got := f.authHeader(); got != "lin_api_test" { + t.Fatalf("Authorization = %q, want bare token (NO 'Bearer ' prefix)", got) + } +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +func TestPreflight_HappyPath(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + if !strings.Contains(body.Query, "viewer") { + t.Errorf("Preflight query should reference viewer; got %q", body.Query) + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"viewer": map[string]any{"id": "u1"}}}) + }) + tr := newTrackerForTest(t, f) + if err := tr.Preflight(ctx()); err != nil { + t.Fatalf("Preflight: %v", err) + } +} + +func TestPreflight_InvalidToken_HTTP401(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"errors": []any{map[string]any{"message": "Authentication required"}}}) + }) + tr := newTrackerForTest(t, f) + if err := tr.Preflight(ctx()); !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +// TestPreflight_GraphQLAuthError covers the more common Linear failure +// mode: HTTP 200 with errors[].extensions.type = "authentication error". +// The SM relies on errors.Is to route, not on HTTP status. +func TestPreflight_GraphQLAuthError(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{ + "errors": []any{map[string]any{ + "message": "auth required", + "extensions": map[string]any{"type": "authentication error"}, + }}, + }) + }) + tr := newTrackerForTest(t, f) + if err := tr.Preflight(ctx()); !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +func TestPreflight_CachesSuccess(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"viewer": map[string]any{"id": "u1"}}}) + }) + tr := newTrackerForTest(t, f) + for i := 0; i < 5; i++ { + if err := tr.Preflight(ctx()); err != nil { + t.Fatalf("Preflight #%d: %v", i, err) + } + } + if got := len(f.calls()); got != 1 { + t.Fatalf("HTTP calls = %d, want 1", got) + } +} + +func TestPreflight_RetriesAfterFailure(t *testing.T) { + var calls int + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + calls++ + if calls == 1 { + writeJSON(w, http.StatusInternalServerError, map[string]any{"errors": []any{map[string]any{"message": "boom"}}}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"viewer": map[string]any{"id": "u1"}}}) + }) + tr := newTrackerForTest(t, f) + if err := tr.Preflight(ctx()); err == nil { + t.Fatalf("first Preflight expected to fail") + } + if err := tr.Preflight(ctx()); err != nil { + t.Fatalf("second Preflight: %v", err) + } + if got := len(f.calls()); got != 2 { + t.Fatalf("HTTP calls = %d, want 2 (fail not cached)", got) + } +} + +// --------------------------------------------------------------------------- +// Get +// --------------------------------------------------------------------------- + +// linearIssuePayload helps build the data.issue body in test handlers. +func linearIssuePayload(identifier, title, body, stateType, url string, labels []string, assignees []string) map[string]any { + labelNodes := make([]map[string]any, len(labels)) + for i, l := range labels { + labelNodes[i] = map[string]any{"name": l} + } + assigneeNodes := make([]map[string]any, len(assignees)) + for i, a := range assignees { + assigneeNodes[i] = map[string]any{"name": a} + } + p := map[string]any{ + "identifier": identifier, + "title": title, + "description": body, + "url": url, + "state": map[string]any{"type": stateType}, + "labels": map[string]any{"nodes": labelNodes}, + "assignees": map[string]any{"nodes": assigneeNodes}, + } + return p +} + +// TestGet_HappyPath_ShortID confirms the adapter passes the opaque +// "TEAMKEY-NUMBER" short id straight through to issue(id:) without parsing +// or rewriting. Linear accepts both UUID and short id at that argument. +func TestGet_HappyPath_ShortID(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + if !strings.Contains(body.Query, "issue(") { + t.Errorf("query should fetch issue; got %q", body.Query) + } + if got, _ := body.Variables["id"].(string); got != "ABC-123" { + t.Errorf("variables.id = %v, want ABC-123", body.Variables["id"]) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "issue": linearIssuePayload("ABC-123", "title", "body", "started", + "https://linear.app/ws/issue/ABC-123", + []string{"bug"}, []string{"alice"}), + }, + }) + }) + tr := newTrackerForTest(t, f) + issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "ABC-123"}) + if err != nil { + t.Fatalf("Get: %v", err) + } + want := domain.Issue{ + ID: domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "ABC-123"}, + Title: "title", + Body: "body", + State: domain.IssueInProgress, + URL: "https://linear.app/ws/issue/ABC-123", + Labels: []string{"bug"}, + Assignees: []string{"alice"}, + } + if !reflect.DeepEqual(issue, want) { + t.Fatalf("issue = %#v\nwant %#v", issue, want) + } +} + +// TestGet_HappyPath_UUID confirms the adapter equally accepts a UUID in +// Native — Linear's issue(id:) takes either. The Native echoed on the +// returned Issue is the same string the caller passed in. +func TestGet_HappyPath_UUID(t *testing.T) { + const uuid = "5af3107a-4f6f-11ec-81d3-0242ac130003" + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + if got, _ := body.Variables["id"].(string); got != uuid { + t.Errorf("variables.id = %v, want uuid", body.Variables["id"]) + } + // Linear still echoes the canonical short identifier on the response. + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "issue": linearIssuePayload("ABC-7", "t", "b", "completed", "u", nil, nil), + }, + }) + }) + tr := newTrackerForTest(t, f) + issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: uuid}) + if err != nil { + t.Fatalf("Get: %v", err) + } + if issue.ID.Native != uuid { + t.Fatalf("returned Native = %q, want %q (original Native should round-trip)", issue.ID.Native, uuid) + } +} + +// TestGet_StateMapping covers every documented Linear state.type value. +// "review" is intentionally NOT a v1 output — Linear has no native review +// type; teams using "In Review" set type=started, which we collapse to +// in_progress. See doc.go for the rationale. +func TestGet_StateMapping(t *testing.T) { + cases := []struct { + linearType string + want domain.NormalizedIssueState + }{ + {"completed", domain.IssueDone}, + {"canceled", domain.IssueCancelled}, + {"started", domain.IssueInProgress}, + {"unstarted", domain.IssueOpen}, + {"triage", domain.IssueOpen}, + {"backlog", domain.IssueOpen}, + {"something_unknown", domain.IssueOpen}, + {"", domain.IssueOpen}, + } + for _, tc := range cases { + t.Run(tc.linearType, func(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "issue": linearIssuePayload("A-1", "t", "b", tc.linearType, "u", nil, nil), + }, + }) + }) + tr := newTrackerForTest(t, f) + issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if err != nil { + t.Fatalf("Get: %v", err) + } + if issue.State != tc.want { + t.Fatalf("state = %q, want %q", issue.State, tc.want) + } + }) + } +} + +// TestGet_NotFound_DataNull pins the contract: Linear returns HTTP 200 +// with data.issue == null when the id isn't visible to the token. The +// adapter must surface this as ErrNotFound so the SM's not-found branch +// is reachable. +func TestGet_NotFound_DataNull(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"issue": nil}}) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestGet_RateLimited_GraphQLError(t *testing.T) { + reset := strconv.FormatInt(time.Now().Add(2*time.Minute).Unix(), 10) + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + w.Header().Set("X-RateLimit-Requests-Reset", reset) + w.Header().Set("Retry-After", "30") + writeJSON(w, http.StatusOK, map[string]any{ + "errors": []any{map[string]any{ + "message": "Too many requests", + "extensions": map[string]any{"type": "ratelimited"}, + }}, + }) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("err = %v, want ErrRateLimited", err) + } + var rle *RateLimitError + if !errors.As(err, &rle) { + t.Fatalf("err = %v, want *RateLimitError", err) + } + if rle.RetryAfter != 30*time.Second { + t.Fatalf("RetryAfter = %v, want 30s", rle.RetryAfter) + } + wantReset, _ := strconv.ParseInt(reset, 10, 64) + if rle.ResetAt.Unix() != wantReset { + t.Fatalf("ResetAt = %v, want unix %d", rle.ResetAt, wantReset) + } +} + +// TestGet_RateLimited_HTTP429 covers Linear's other surface for rate +// limiting — some endpoints return 429 with headers and no recognized +// errors[].extensions.type. The classifier must still recognize this as +// ErrRateLimited AND parse both Retry-After and X-RateLimit-Requests-Reset +// off the headers so the SM can back off intelligently. +func TestGet_RateLimited_HTTP429(t *testing.T) { + reset := time.Now().Add(90 * time.Second).Unix() + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + w.Header().Set("Retry-After", "5") + w.Header().Set("X-RateLimit-Requests-Reset", strconv.FormatInt(reset, 10)) + writeJSON(w, http.StatusTooManyRequests, map[string]any{"errors": []any{map[string]any{"message": "rate limit"}}}) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if !errors.Is(err, ErrRateLimited) { + t.Fatalf("err = %v, want ErrRateLimited", err) + } + var rle *RateLimitError + if !errors.As(err, &rle) { + t.Fatalf("err = %v, want *RateLimitError", err) + } + if rle.RetryAfter != 5*time.Second { + t.Fatalf("RetryAfter = %v, want 5s", rle.RetryAfter) + } + if rle.ResetAt.Unix() != reset { + t.Fatalf("ResetAt = %d, want %d", rle.ResetAt.Unix(), reset) + } +} + +func TestGet_AuthFailed_HTTP401(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusUnauthorized, map[string]any{"errors": []any{map[string]any{"message": "Authentication required"}}}) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +func TestGet_AuthFailed_GraphQLError(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{ + "errors": []any{map[string]any{ + "message": "Authentication required, not authenticated", + "extensions": map[string]any{"type": "authentication error"}, + }}, + }) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +// TestGet_GraphQLAuthError_CaseInsensitive pins the extensions.type +// normalization: the classifier lower-cases before matching, so an +// upstream change that ships "Authentication Error" (or trailing +// whitespace) still routes to ErrAuthFailed instead of silently +// degrading to a generic graphql-error. Silent degradation here would +// break the SM's recovery branch. +func TestGet_GraphQLAuthError_CaseInsensitive(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{ + "errors": []any{map[string]any{ + "message": "auth required", + "extensions": map[string]any{"type": " Authentication Error "}, + }}, + }) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +// TestGet_Forbidden_GraphQLError covers the second auth-class +// extensions.type ("forbidden"). The Linear SDK ships both +// AuthenticationLinearError and ForbiddenLinearError as distinct types; +// for v1 we fold both onto ErrAuthFailed because the SM's recovery is +// identical (alert and stop). This test pins the mapping. +func TestGet_Forbidden_GraphQLError(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{ + "errors": []any{map[string]any{ + "message": "not allowed", + "extensions": map[string]any{"type": "forbidden"}, + }}, + }) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +// TestGet_FeatureNotAccessible folds Linear's "feature not accessible" +// (plan-gated query, e.g. workspace not on the plan that exposes the +// queried field) onto ErrAuthFailed. Treating it as an auth-class failure +// keeps the SM's recovery surface coherent — both mean "this token can't +// satisfy the request" and require human intervention. +func TestGet_FeatureNotAccessible(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{ + "errors": []any{map[string]any{ + "message": "feature is not accessible on this plan", + "extensions": map[string]any{"type": "feature not accessible"}, + }}, + }) + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if !errors.Is(err, ErrAuthFailed) { + t.Fatalf("err = %v, want ErrAuthFailed", err) + } +} + +func TestGet_RejectsWrongProvider(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + t.Errorf("must not hit network on wrong provider") + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderGitHub, Native: "o/r#1"}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("err = %v, want ErrWrongProvider", err) + } +} + +func TestGet_RejectsEmptyProvider(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + t.Errorf("must not hit network on empty provider") + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Native: "A-1"}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("err = %v, want ErrWrongProvider", err) + } +} + +// TestGet_RejectsEmptyNative locks the one validation the adapter does +// over Native: empty strings can't possibly route to an issue, and +// passing them to Linear's issue(id:"") would just return a confusing +// auth-shaped error. +func TestGet_RejectsEmptyNative(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + t.Errorf("must not hit network on empty native id") + }) + tr := newTrackerForTest(t, f) + _, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: ""}) + if !errors.Is(err, ErrBadID) { + t.Fatalf("err = %v, want ErrBadID", err) + } +} + +// TestGet_CanonicalizesProviderOnOutput keeps parity with the github +// adapter: callers must be able to re-route the returned Issue without +// inspecting which adapter produced it. +func TestGet_CanonicalizesProviderOnOutput(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "issue": linearIssuePayload("A-1", "t", "b", "unstarted", "u", nil, nil), + }, + }) + }) + tr := newTrackerForTest(t, f) + issue, err := tr.Get(ctx(), domain.TrackerID{Provider: domain.TrackerProviderLinear, Native: "A-1"}) + if err != nil { + t.Fatalf("Get: %v", err) + } + if issue.ID.Provider != domain.TrackerProviderLinear { + t.Fatalf("issue.ID.Provider = %q, want %q", issue.ID.Provider, domain.TrackerProviderLinear) + } +} + +// --------------------------------------------------------------------------- +// List +// --------------------------------------------------------------------------- + +// TestList_WorkspaceWide_NoTeamFilter: empty repo.Native means the SM is +// asking for a workspace-wide enumeration; the adapter must skip the +// teams() lookup AND omit team from the filter argument. +func TestList_WorkspaceWide_NoTeamFilter(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + if strings.Contains(body.Query, "teams(") { + t.Errorf("must NOT issue a teams() lookup when repo is empty; got %q", body.Query) + } + // The filter variables map must not contain a team selector. + if filt, _ := body.Variables["filter"].(map[string]any); filt != nil { + if _, ok := filt["team"]; ok { + t.Errorf("filter.team should be absent for workspace-wide list, got %v", filt["team"]) + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "issues": map[string]any{"nodes": []any{ + linearIssuePayload("A-1", "t1", "b1", "started", "u1", nil, nil), + }}, + }, + }) + }) + tr := newTrackerForTest(t, f) + issues, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: ""}, domain.ListFilter{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(issues) != 1 || issues[0].ID.Native != "A-1" { + t.Fatalf("issues = %#v", issues) + } +} + +// TestList_TeamScoped_ResolvesAndCachesTeamUUID is the load-bearing +// contract for repo.Native = team key: the adapter must resolve the team +// key to its UUID lazily via teams(filter:{key:{eq:$key}}, first:1), pass +// the UUID into the issues() filter, AND cache it so subsequent calls +// for the same team don't burn another teams() roundtrip. +func TestList_TeamScoped_ResolvesAndCachesTeamUUID(t *testing.T) { + const teamUUID = "00000000-0000-0000-0000-000000000abc" + var teamCalls, issuesCalls int + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + switch { + case strings.Contains(body.Query, "teams("): + teamCalls++ + if got, _ := body.Variables["key"].(string); got != "ABC" { + t.Errorf("teams() variables.key = %v, want ABC", body.Variables["key"]) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "teams": map[string]any{"nodes": []any{map[string]any{"id": teamUUID, "key": "ABC"}}}, + }, + }) + case strings.Contains(body.Query, "issues("): + issuesCalls++ + filt, _ := body.Variables["filter"].(map[string]any) + team, _ := filt["team"].(map[string]any) + id, _ := team["id"].(map[string]any) + if got, _ := id["eq"].(string); got != teamUUID { + t.Errorf("filter.team.id.eq = %v, want %s", id["eq"], teamUUID) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"issues": map[string]any{"nodes": []any{}}}, + }) + default: + t.Errorf("unexpected query: %s", body.Query) + } + }) + tr := newTrackerForTest(t, f) + for i := 0; i < 3; i++ { + if _, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: "ABC"}, domain.ListFilter{}); err != nil { + t.Fatalf("List #%d: %v", i, err) + } + } + if teamCalls != 1 { + t.Fatalf("teams() lookups = %d, want 1 (must be cached after first hit)", teamCalls) + } + if issuesCalls != 3 { + t.Fatalf("issues() lookups = %d, want 3", issuesCalls) + } +} + +// TestList_TeamNotFound covers the "key resolves to no team" branch. +// The SM's caller asked to scope to a team that the token can't see (or +// that doesn't exist) — ErrNotFound is the truthful answer. +func TestList_TeamNotFound(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + if !strings.Contains(body.Query, "teams(") { + t.Errorf("expected teams() lookup, got %q", body.Query) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"teams": map[string]any{"nodes": []any{}}}, + }) + }) + tr := newTrackerForTest(t, f) + _, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: "NOPE"}, domain.ListFilter{}) + if !errors.Is(err, ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + +func TestList_StateFilter(t *testing.T) { + // open → unstarted, started, triage, backlog + // closed → completed, canceled + // all → no state filter + cases := []struct { + name string + filter domain.ListStateFilter + wantTypes []string + wantNoSet bool + }{ + {"open", domain.ListOpen, []string{"unstarted", "started", "triage", "backlog"}, false}, + {"closed", domain.ListClosed, []string{"completed", "canceled"}, false}, + {"all", domain.ListAll, nil, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + filt, _ := body.Variables["filter"].(map[string]any) + state, hasState := filt["state"].(map[string]any) + if tc.wantNoSet { + if hasState { + t.Errorf("filter.state should be absent for all; got %v", filt["state"]) + } + } else { + typ, _ := state["type"].(map[string]any) + gotIface, _ := typ["in"].([]any) + got := make([]string, len(gotIface)) + for i, v := range gotIface { + got[i], _ = v.(string) + } + if !reflect.DeepEqual(got, tc.wantTypes) { + t.Errorf("filter.state.type.in = %v, want %v", got, tc.wantTypes) + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"issues": map[string]any{"nodes": []any{}}}, + }) + }) + tr := newTrackerForTest(t, f) + if _, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: ""}, domain.ListFilter{State: tc.filter}); err != nil { + t.Fatalf("List: %v", err) + } + }) + } +} + +func TestList_AssigneeAndLabels(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + filt, _ := body.Variables["filter"].(map[string]any) + ass, _ := filt["assignee"].(map[string]any) + name, _ := ass["name"].(map[string]any) + if got, _ := name["eq"].(string); got != "alice" { + t.Errorf("filter.assignee.name.eq = %v, want alice", name["eq"]) + } + lab, _ := filt["labels"].(map[string]any) + ln, _ := lab["name"].(map[string]any) + gotIface, _ := ln["in"].([]any) + got := make([]string, len(gotIface)) + for i, v := range gotIface { + got[i], _ = v.(string) + } + want := []string{"bug", "help wanted"} + if !reflect.DeepEqual(got, want) { + t.Errorf("filter.labels.name.in = %v, want %v", got, want) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"issues": map[string]any{"nodes": []any{}}}, + }) + }) + tr := newTrackerForTest(t, f) + if _, err := tr.List(ctx(), + domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: ""}, + domain.ListFilter{Assignee: "alice", Labels: []string{"bug", "help wanted"}}, + ); err != nil { + t.Fatalf("List: %v", err) + } +} + +// TestList_LimitDefaultAndCap pins the silent-cap contract from the port +// docstring: zero means "adapter default" (50, Linear's pagination +// default), and asking for more than the cap returns exactly cap results +// without error. Linear's hard cap on first: is 250. +func TestList_LimitDefaultAndCap(t *testing.T) { + cases := []struct { + name string + in int + wantFirst float64 + }{ + {"zero → default 50", 0, 50}, + {"custom 100", 100, 100}, + {"capped at 250", 9999, 250}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + got, _ := body.Variables["first"].(float64) + if got != tc.wantFirst { + t.Errorf("variables.first = %v, want %v", got, tc.wantFirst) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"issues": map[string]any{"nodes": []any{}}}, + }) + }) + tr := newTrackerForTest(t, f) + if _, err := tr.List(ctx(), + domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: ""}, + domain.ListFilter{Limit: tc.in}, + ); err != nil { + t.Fatalf("List: %v", err) + } + }) + } +} + +func TestList_RejectsWrongProvider(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + t.Errorf("must not hit network on wrong provider") + }) + tr := newTrackerForTest(t, f) + _, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderGitHub, Native: "o/r"}, domain.ListFilter{}) + if !errors.Is(err, ErrWrongProvider) { + t.Fatalf("err = %v, want ErrWrongProvider", err) + } +} + +// TestList_CanonicalizesProviderOnOutput keeps parity with Get. +func TestList_CanonicalizesProviderOnOutput(t *testing.T) { + f := newFakeLinear(t, func(t *testing.T, w http.ResponseWriter, body graphqlBody) { + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"issues": map[string]any{"nodes": []any{ + linearIssuePayload("A-1", "t", "b", "started", "u", nil, nil), + }}}, + }) + }) + tr := newTrackerForTest(t, f) + issues, err := tr.List(ctx(), domain.TrackerRepo{Provider: domain.TrackerProviderLinear, Native: ""}, domain.ListFilter{}) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(issues) != 1 || issues[0].ID.Provider != domain.TrackerProviderLinear { + t.Fatalf("issues = %#v", issues) + } +} From 4bb60b49d016dd4153322765821a3a98bd82bba1 Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Sun, 31 May 2026 00:07:31 +0530 Subject: [PATCH 2/2] feat(tracker): actionable Linear ErrNoToken message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../internal/adapters/tracker/linear/auth.go | 48 +++++++++++++++++-- .../internal/adapters/tracker/linear/doc.go | 15 ++++++ .../adapters/tracker/linear/tracker_test.go | 46 ++++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/backend/internal/adapters/tracker/linear/auth.go b/backend/internal/adapters/tracker/linear/auth.go index d2a66153..c77ca0fe 100644 --- a/backend/internal/adapters/tracker/linear/auth.go +++ b/backend/internal/adapters/tracker/linear/auth.go @@ -3,10 +3,23 @@ package linear import ( "context" "errors" + "fmt" "os" "strings" ) +// DefaultEnvVar is the env var EnvTokenSource falls back to when nothing +// project-specific is configured. Exported so callers (CLI help text, +// onboarding docs) can reference the same constant the adapter actually +// reads. +const DefaultEnvVar = "LINEAR_API_KEY" + +// APIKeySettingsURL is where a user goes in the Linear web UI to mint a +// personal API key. Surfaced in error messages so a fresh dev hits a +// failed Preflight, copies the URL, and is unblocked in seconds — no +// docs hunt required. +const APIKeySettingsURL = "https://linear.app/settings/api" + // TokenSource yields a Linear personal API key on demand. Mirrors the // GitHub adapter's TokenSource so the Session Manager only needs to know // one shape across providers. The Tracker calls Token once at construction @@ -16,8 +29,14 @@ type TokenSource interface { Token(ctx context.Context) (string, error) } -// ErrNoToken is returned when no token source could yield a non-empty token. -var ErrNoToken = errors.New("linear tracker: no token configured") +// ErrNoToken is returned when no token source could yield a non-empty +// token. The message is intentionally actionable — Linear has no +// CLI-stored-token surface like gh's keyring, so the only path is a +// personal API key in an env var. Pointing the user at the settings URL +// and the env var name turns a generic "no token" failure into a +// one-step fix without grepping our docs. +var ErrNoToken = errors.New("linear tracker: no token configured — create a personal API key at " + + APIKeySettingsURL + " and export it as " + DefaultEnvVar) // StaticTokenSource is a literal token, typically used in tests. type StaticTokenSource string @@ -43,8 +62,29 @@ func (s EnvTokenSource) Token(context.Context) (string, error) { return v, nil } } - if v := strings.TrimSpace(os.Getenv("LINEAR_API_KEY")); v != "" { + if v := strings.TrimSpace(os.Getenv(DefaultEnvVar)); v != "" { return v, nil } - return "", ErrNoToken + // Wrap ErrNoToken so errors.Is still matches, but enumerate the env + // vars we actually checked so the user sees what to set instead of + // guessing. The deduped list mirrors lookup order, with the default + // appended when it wasn't already listed. + tried := dedup(append(append([]string{}, s.EnvVars...), DefaultEnvVar)) + return "", fmt.Errorf("%w (checked: %s)", ErrNoToken, strings.Join(tried, ", ")) +} + +func dedup(in []string) []string { + seen := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, v := range in { + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out } diff --git a/backend/internal/adapters/tracker/linear/doc.go b/backend/internal/adapters/tracker/linear/doc.go index 76d69a5e..ac3391dd 100644 --- a/backend/internal/adapters/tracker/linear/doc.go +++ b/backend/internal/adapters/tracker/linear/doc.go @@ -17,6 +17,21 @@ // Writing back to the tracker (Comment, Transition) is deferred to issue // #40. The observer / polling loop is deferred to #35. // +// # Getting started +// +// Linear has no equivalent of the github gh CLI's keyring, so v1 only +// supports a personal API key sourced from an env var. Two steps: +// +// 1. Mint a personal API key at https://linear.app/settings/api. +// 2. Export it as LINEAR_API_KEY (the default EnvTokenSource fallback). +// Projects that need per-project tokens can configure additional +// env-var names via EnvTokenSource.EnvVars; the listed names are +// consulted in order and LINEAR_API_KEY is the final fallback. +// +// When no token can be sourced, the adapter returns ErrNoToken. The +// error message names both the settings URL and the env var so a fresh +// dev hitting it sees the fix without reading these docs first. +// // # Authentication // // Linear personal API keys are sent as a RAW Authorization header value diff --git a/backend/internal/adapters/tracker/linear/tracker_test.go b/backend/internal/adapters/tracker/linear/tracker_test.go index 3dffea02..0a4835e2 100644 --- a/backend/internal/adapters/tracker/linear/tracker_test.go +++ b/backend/internal/adapters/tracker/linear/tracker_test.go @@ -129,6 +129,52 @@ func TestNewRejectsMissingToken(t *testing.T) { } } +// TestErrNoToken_MessageIsActionable pins the contract that the +// ErrNoToken sentinel's message string contains both the env-var name +// the user must set AND the settings URL where they mint the key. The +// SM surfaces this string verbatim — losing either piece turns a +// one-step fix into a docs hunt. +func TestErrNoToken_MessageIsActionable(t *testing.T) { + msg := ErrNoToken.Error() + for _, want := range []string{"LINEAR_API_KEY", "https://linear.app/settings/api"} { + if !strings.Contains(msg, want) { + t.Errorf("ErrNoToken.Error() missing %q; got %q", want, msg) + } + } +} + +// TestEnvTokenSource_EnumeratesCheckedVars verifies the EnvTokenSource +// failure path lists every env var it actually consulted — in lookup +// order, no duplicates — so the user sees what to set. The wrapping +// preserves errors.Is(err, ErrNoToken) so SM routing is unchanged. +func TestEnvTokenSource_EnumeratesCheckedVars(t *testing.T) { + t.Setenv("AO_LINEAR_TOKEN", "") + t.Setenv("LINEAR_API_KEY", "") + src := EnvTokenSource{EnvVars: []string{"AO_LINEAR_TOKEN", "LINEAR_API_KEY"}} + _, err := src.Token(ctx()) + if !errors.Is(err, ErrNoToken) { + t.Fatalf("err = %v, want errors.Is(ErrNoToken)", err) + } + msg := err.Error() + for _, want := range []string{"AO_LINEAR_TOKEN", "LINEAR_API_KEY", "checked:"} { + if !strings.Contains(msg, want) { + t.Errorf("error missing %q; got %q", want, msg) + } + } + // The "(checked: ...)" suffix must not duplicate LINEAR_API_KEY when + // the caller already listed it. We slice on the prefix so the + // sentinel's own mention of LINEAR_API_KEY (which is allowed and + // intentional) doesn't confuse the count. + idx := strings.Index(msg, "checked:") + if idx < 0 { + t.Fatalf("error missing checked: list; got %q", msg) + } + suffix := msg[idx:] + if got := strings.Count(suffix, "LINEAR_API_KEY"); got != 1 { + t.Errorf("LINEAR_API_KEY appears %d times in checked-list %q, want 1 (caller listed it; default must not duplicate)", got, suffix) + } +} + // TestAuthHeader_NoBearerPrefix pins the single easiest bug to introduce on // this adapter: Linear personal API keys are sent as raw "Authorization: // ", NOT "Authorization: Bearer ". OAuth tokens DO use Bearer but