Files
kandev/apps/backend/internal/linear/graphql_client.go
T
João Salavisa 7b5b3f329d feat: add Linear integration (#736)
* feat: add Linear integration

Mirrors the existing Jira integration so a workspace can connect to Linear
with a personal API key, browse and search issues, and start a Kandev task
from any issue. Backend uses Linear's GraphQL API with a workspace-scoped
config + secret store and a 90s auth-health poller; the frontend adds a
settings page, an issue dialog with state transitions, link/import buttons
in the task UI, and a paginated /linear browse page with a Start task flow.

* fix(linear): address PR review feedback

- Route httpListStates through Service.ListStates instead of clientFor
- Extract repeated workspace_id required string into a constant
- Log GetConfig errors during credential resolution instead of dropping them
- Clear popover error state on close in import + link buttons
- Pick the first workflow with steps in the quick task launcher (don't hard-pin to workflows[0])
- Reset useLinearEnabled state on workspace change via tag-based pattern
- Add cursor-pointer to anchor + Link elements per project convention
- Add tests: handlers_test.go, linear-api.test.ts, use-linear-enabled.test.ts, linear-issue-common.test.tsx
- Document internal/jira and internal/linear in CLAUDE.md/AGENTS.md

* fix(linear): address second-round PR review feedback

- Round Linear's fractional state position instead of truncating it via
  int(math.Round(s.Position)) so adjacent fractional positions don't collapse
- Force LinearSettings to remount on workspace change with key={id} so the
  previous workspace's config / form / testResult / teams cannot bleed into
  the next workspace before the new fetches settle
- Broaden the legacy-value useLinearEnabled test to cover "true", "1", "yes",
  and "legacy" so a regression to strict value === "true" would fail

* fix(linear): address third-round PR review feedback

- Propagate context.Canceled / context.DeadlineExceeded from ListStates in
  GetIssue instead of swallowing them — a caller cancellation must not return
  a partial-success response
- Drop the one-off contains() test helper in handlers_test.go and use
  strings.Contains directly so the assertion is self-documenting
- Add getLinearIssue URL-shape test that locks identifier-as-path-segment
  vs workspaceId-as-query-param so accidentally swapping them would fail

* test(linear): extract repeated literals in linear-api.test.ts

Pulls "ws-1", the API base URL, and the auth-method literal into module-
level constants so the assertions read as the route shape they care about
and the duplicate-strings ESLint rule (currently warn, threshold 4)
stays quiet as the file grows.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-01 10:50:14 +01:00

536 lines
16 KiB
Go

package linear
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"strings"
"time"
)
// LinearAPIEndpoint is the single GraphQL endpoint exposed by Linear. There is
// no per-organization host: auth scopes the request to the right workspace.
const LinearAPIEndpoint = "https://api.linear.app/graphql"
// GraphQLClient talks to Linear's GraphQL API using a Personal API Key. The
// client holds no state beyond the credential so it can be recreated cheaply
// per workspace if config changes.
type GraphQLClient struct {
http *http.Client
endpoint string
apiKey string
maxBodySize int64
}
// NewGraphQLClient builds a client from a LinearConfig + secret.
func NewGraphQLClient(_ *LinearConfig, secret string) *GraphQLClient {
return &GraphQLClient{
http: &http.Client{
Timeout: 30 * time.Second,
},
endpoint: LinearAPIEndpoint,
apiKey: secret,
maxBodySize: 4 << 20, // 4 MB — Linear payloads are small by design.
}
}
const userAgent = "kandev/1.0 (+https://github.com/kdlbs/kandev)"
// graphqlRequest is the standard {query, variables} envelope.
type graphqlRequest struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
}
// graphqlError captures one entry from the GraphQL `errors` array.
type graphqlError struct {
Message string `json:"message"`
Path []interface{} `json:"path,omitempty"`
Extensions map[string]interface{} `json:"extensions,omitempty"`
}
// graphqlResponse is the raw envelope; `Data` is decoded into the caller's
// struct via a second pass to avoid generics.
type graphqlResponse struct {
Data json.RawMessage `json:"data"`
Errors []graphqlError `json:"errors,omitempty"`
}
// do executes a GraphQL operation and decodes `data` into out (may be nil).
// Non-2xx responses and GraphQL-level errors both surface as *APIError so
// callers can switch on status. Linear returns 4xx for auth failures and 200
// with errors[] for everything else.
func (c *GraphQLClient) do(ctx context.Context, query string, variables map[string]interface{}, out interface{}) error {
body, err := json.Marshal(graphqlRequest{Query: query, Variables: variables})
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", userAgent)
// Linear accepts the API key directly as the Authorization header value.
req.Header.Set("Authorization", c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
raw, err := io.ReadAll(io.LimitReader(resp.Body, c.maxBodySize))
if err != nil {
return err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return &APIError{StatusCode: resp.StatusCode, Message: summarizeBody(raw)}
}
var env graphqlResponse
if err := json.Unmarshal(raw, &env); err != nil {
return &APIError{StatusCode: resp.StatusCode, Message: "invalid GraphQL response: " + err.Error()}
}
if len(env.Errors) > 0 {
return &APIError{StatusCode: graphqlErrorStatus(env.Errors), Message: joinGraphQLErrors(env.Errors)}
}
if out == nil || len(env.Data) == 0 {
return nil
}
return json.Unmarshal(env.Data, out)
}
// graphqlErrorStatus maps a GraphQL error envelope to an HTTP-like status. Auth
// failures get 401 so the UI can surface them as "credentials invalid";
// everything else falls back to 400 (request-level failure rather than server).
func graphqlErrorStatus(errs []graphqlError) int {
for _, e := range errs {
if e.Extensions == nil {
continue
}
if t, ok := e.Extensions["type"].(string); ok {
switch strings.ToLower(t) {
case "authentication error", "authentication", "unauthenticated":
return http.StatusUnauthorized
case "feature not access", "permission", "forbidden":
return http.StatusForbidden
case "invalid input":
return http.StatusBadRequest
}
}
}
return http.StatusBadRequest
}
func joinGraphQLErrors(errs []graphqlError) string {
if len(errs) == 1 {
return errs[0].Message
}
parts := make([]string, 0, len(errs))
for _, e := range errs {
parts = append(parts, e.Message)
}
return strings.Join(parts, "; ")
}
// summarizeBody trims an error body to a useful length for log/error messages.
func summarizeBody(raw []byte) string {
const maxMsg = 500
s := strings.TrimSpace(string(raw))
if len(s) > maxMsg {
return s[:maxMsg] + "…"
}
if s == "" {
return "(empty body)"
}
return s
}
// --- viewer / TestAuth ---
const viewerQuery = `
query Viewer {
viewer {
id
name
displayName
email
}
organization {
urlKey
name
}
}`
type viewerData struct {
Viewer struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
} `json:"viewer"`
Organization struct {
URLKey string `json:"urlKey"`
Name string `json:"name"`
} `json:"organization"`
}
// TestAuth hits the viewer query — the cheapest authenticated query Linear
// exposes — and returns a structured result so the UI can render a meaningful
// failure inline.
func (c *GraphQLClient) TestAuth(ctx context.Context) (*TestConnectionResult, error) {
var data viewerData
if err := c.do(ctx, viewerQuery, nil, &data); err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
return &TestConnectionResult{OK: false, Error: apiErr.Error()}, nil
}
return &TestConnectionResult{OK: false, Error: err.Error()}, nil
}
name := data.Viewer.DisplayName
if name == "" {
name = data.Viewer.Name
}
return &TestConnectionResult{
OK: true,
UserID: data.Viewer.ID,
DisplayName: name,
Email: data.Viewer.Email,
OrgSlug: data.Organization.URLKey,
OrgName: data.Organization.Name,
}, nil
}
// --- teams ---
const teamsQuery = `
query Teams {
teams(first: 100, orderBy: updatedAt) {
nodes { id key name }
}
}`
type teamsData struct {
Teams struct {
Nodes []struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
} `json:"nodes"`
} `json:"teams"`
}
// ListTeams returns up to 100 teams (Linear's max page size). Fine for the
// settings dropdown; pagination can be added later if needed.
func (c *GraphQLClient) ListTeams(ctx context.Context) ([]LinearTeam, error) {
var data teamsData
if err := c.do(ctx, teamsQuery, nil, &data); err != nil {
return nil, err
}
out := make([]LinearTeam, 0, len(data.Teams.Nodes))
for _, t := range data.Teams.Nodes {
out = append(out, LinearTeam{ID: t.ID, Key: t.Key, Name: t.Name})
}
return out, nil
}
// --- workflow states ---
const teamStatesQuery = `
query TeamStates($filter: WorkflowStateFilter!) {
workflowStates(first: 100, filter: $filter) {
nodes { id name type color position }
}
}`
type statesData struct {
WorkflowStates struct {
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Color string `json:"color"`
Position float64 `json:"position"`
} `json:"nodes"`
} `json:"workflowStates"`
}
// ListStates returns the workflow states for a team identified by its key.
// Equivalent of Jira's "ListTransitions" but unconditional — the same set of
// states is available regardless of the issue's current state.
func (c *GraphQLClient) ListStates(ctx context.Context, teamKey string) ([]LinearWorkflowState, error) {
if teamKey == "" {
return nil, fmt.Errorf("teamKey required")
}
vars := map[string]interface{}{
"filter": map[string]interface{}{
"team": map[string]interface{}{"key": map[string]interface{}{"eq": teamKey}},
},
}
var data statesData
if err := c.do(ctx, teamStatesQuery, vars, &data); err != nil {
return nil, err
}
out := make([]LinearWorkflowState, 0, len(data.WorkflowStates.Nodes))
for _, s := range data.WorkflowStates.Nodes {
out = append(out, LinearWorkflowState{
ID: s.ID,
Name: s.Name,
Type: s.Type,
// Linear uses fractional indexing for state ordering — round
// rather than truncate so adjacent fractional positions don't
// collapse onto the same int.
Color: s.Color,
Position: int(math.Round(s.Position)),
})
}
return out, nil
}
// --- issues ---
// issueFragment is the projection used by both single-issue fetches and search
// results. Centralised so the wire schema stays consistent.
const issueFragment = `
id
identifier
title
description
url
updatedAt
priority
priorityLabel
state { id name type color }
team { id key }
assignee { name email avatarUrl }
creator { name avatarUrl }
`
type issueNode struct {
ID string `json:"id"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Description string `json:"description"`
URL string `json:"url"`
UpdatedAt string `json:"updatedAt"`
Priority int `json:"priority"`
PriorityLab string `json:"priorityLabel"`
State struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Color string `json:"color"`
} `json:"state"`
Team struct {
ID string `json:"id"`
Key string `json:"key"`
} `json:"team"`
Assignee *struct {
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatarUrl"`
} `json:"assignee"`
Creator *struct {
Name string `json:"name"`
AvatarURL string `json:"avatarUrl"`
} `json:"creator"`
}
func issueNodeToIssue(n *issueNode) LinearIssue {
out := LinearIssue{
ID: n.ID,
Identifier: n.Identifier,
Title: n.Title,
Description: n.Description,
StateID: n.State.ID,
StateName: n.State.Name,
StateType: n.State.Type,
StateCategory: stateCategory(n.State.Type),
TeamID: n.Team.ID,
TeamKey: n.Team.Key,
Priority: n.Priority,
PriorityLabel: n.PriorityLab,
Updated: n.UpdatedAt,
URL: n.URL,
}
if n.Assignee != nil {
out.AssigneeName = n.Assignee.Name
out.AssigneeEmail = n.Assignee.Email
out.AssigneeIcon = n.Assignee.AvatarURL
}
if n.Creator != nil {
out.CreatorName = n.Creator.Name
out.CreatorIcon = n.Creator.AvatarURL
}
return out
}
// stateCategory maps Linear's state type onto Jira's three-bucket category so
// the frontend can style status pills uniformly across integrations.
func stateCategory(stateType string) string {
switch strings.ToLower(stateType) {
case "backlog", "unstarted", "triage":
return "new"
case "started":
return "indeterminate"
case "completed", "canceled":
return "done"
default:
return "new"
}
}
const issueByIDQuery = `
query IssueByID($id: String!) {
issue(id: $id) {` + issueFragment + `}
}`
// GetIssue loads a Linear issue by identifier (e.g. "ENG-123") and attaches
// the team's available workflow states. Linear's `issue(id)` field accepts
// either the UUID or the human identifier.
func (c *GraphQLClient) GetIssue(ctx context.Context, identifier string) (*LinearIssue, error) {
var data struct {
Issue *issueNode `json:"issue"`
}
if err := c.do(ctx, issueByIDQuery, map[string]interface{}{"id": identifier}, &data); err != nil {
return nil, err
}
if data.Issue == nil {
return nil, &APIError{StatusCode: http.StatusNotFound, Message: "issue not found: " + identifier}
}
issue := issueNodeToIssue(data.Issue)
states, err := c.ListStates(ctx, issue.TeamKey)
if err != nil {
// Caller cancellation / deadline must propagate — returning a
// partial-success response after the request was abandoned would
// break downstream correctness.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
var apiErr *APIError
if errors.As(err, &apiErr) && apiErr.StatusCode >= 400 && apiErr.StatusCode < 500 {
return nil, err
}
// Network blip or 5xx: keep the issue and let the UI render without
// the state selector. The user can still read the issue and refresh.
} else {
issue.States = states
}
return &issue, nil
}
// --- mutations ---
const setStateMutation = `
mutation SetIssueState($id: String!, $stateId: String!) {
issueUpdate(id: $id, input: { stateId: $stateId }) {
success
}
}`
type setStateData struct {
IssueUpdate struct {
Success bool `json:"success"`
} `json:"issueUpdate"`
}
// SetIssueState moves an issue to a new workflow state. issueID may be either
// the UUID or the human identifier — Linear accepts both. Mirrors Jira's
// DoTransition.
func (c *GraphQLClient) SetIssueState(ctx context.Context, issueID, stateID string) error {
var data setStateData
vars := map[string]interface{}{"id": issueID, "stateId": stateID}
if err := c.do(ctx, setStateMutation, vars, &data); err != nil {
return err
}
if !data.IssueUpdate.Success {
return &APIError{StatusCode: http.StatusInternalServerError, Message: "issueUpdate returned success=false"}
}
return nil
}
// --- search ---
const searchIssuesQuery = `
query SearchIssues($filter: IssueFilter, $first: Int!, $after: String) {
issues(filter: $filter, first: $first, after: $after, orderBy: updatedAt) {
nodes {` + issueFragment + `}
pageInfo { hasNextPage endCursor }
}
}`
type searchIssuesData struct {
Issues struct {
Nodes []issueNode `json:"nodes"`
PageInfo struct {
HasNextPage bool `json:"hasNextPage"`
EndCursor string `json:"endCursor"`
} `json:"pageInfo"`
} `json:"issues"`
}
// SearchIssues runs a filtered search and returns a page of issues. pageToken
// is the `endCursor` returned in the previous page; pass "" for the first
// page. maxResults is capped at 100 (Linear's max).
func (c *GraphQLClient) SearchIssues(ctx context.Context, filter SearchFilter, pageToken string, maxResults int) (*SearchResult, error) {
if maxResults <= 0 {
maxResults = 25
}
if maxResults > 100 {
maxResults = 100
}
gqlFilter := buildIssueFilter(filter)
vars := map[string]interface{}{"filter": gqlFilter, "first": maxResults}
if pageToken != "" {
vars["after"] = pageToken
}
var data searchIssuesData
if err := c.do(ctx, searchIssuesQuery, vars, &data); err != nil {
return nil, err
}
out := &SearchResult{
MaxResults: maxResults,
IsLast: !data.Issues.PageInfo.HasNextPage,
NextPageToken: data.Issues.PageInfo.EndCursor,
Issues: make([]LinearIssue, 0, len(data.Issues.Nodes)),
}
for i := range data.Issues.Nodes {
out.Issues = append(out.Issues, issueNodeToIssue(&data.Issues.Nodes[i]))
}
return out, nil
}
// buildIssueFilter translates our SearchFilter into Linear's IssueFilter input.
// Empty fields are dropped so we don't send `{ "team": null }` and similar
// (which Linear treats as a typed null and rejects).
func buildIssueFilter(f SearchFilter) map[string]interface{} {
out := map[string]interface{}{}
if q := strings.TrimSpace(f.Query); q != "" {
// Linear has no top-level free-text field, but `searchableContent`
// matches across title and description.
out["searchableContent"] = map[string]interface{}{"contains": q}
}
if f.TeamKey != "" {
out["team"] = map[string]interface{}{"key": map[string]interface{}{"eq": f.TeamKey}}
}
if len(f.StateIDs) > 0 {
out["state"] = map[string]interface{}{"id": map[string]interface{}{"in": f.StateIDs}}
}
switch f.Assigned {
case "me":
// `null: false` + assignee filter would require the viewer ID; instead
// we use the `viewer` shortcut Linear exposes via `assignee.isMe`.
out["assignee"] = map[string]interface{}{"isMe": map[string]interface{}{"eq": true}}
case "unassigned":
out["assignee"] = map[string]interface{}{"null": true}
}
if len(out) == 0 {
return nil
}
return out
}