Files
kandev/apps/backend/internal/linear/graphql_client.go
T

752 lines
22 KiB
Go

package linear
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"regexp"
"strconv"
"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
createdAt
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"`
CreatedAt string `json:"createdAt"`
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,
Created: n.CreatedAt,
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
}
// issueIdentifierRe matches a Linear-style ticket identifier like ENG-123.
// Team keys per Linear are uppercase alphanumerics + underscore, starting with
// a letter.
var issueIdentifierRe = regexp.MustCompile(`^([A-Z][A-Z0-9_]*)-(\d+)$`)
// parseIssueIdentifier returns the team key and issue number when q looks like
// a Linear identifier (e.g. "ENG-123"). Input is trimmed and upper-cased so
// "eng-123" works too. ok=false means q is not an identifier and callers
// should treat it as a free-text query.
func parseIssueIdentifier(q string) (string, int, bool) {
q = strings.ToUpper(strings.TrimSpace(q))
if q == "" {
return "", 0, false
}
m := issueIdentifierRe.FindStringSubmatch(q)
if m == nil {
return "", 0, false
}
n, err := strconv.Atoi(m[2])
if err != nil {
return "", 0, false
}
return m[1], n, true
}
// issueNumberRe matches a bare issue number like "2438" — what a user types
// when searching for a ticket by its number without the team prefix.
var issueNumberRe = regexp.MustCompile(`^\d+$`)
// parseIssueNumber returns the issue number when q is a bare integer. ok=false
// means q is not a plain number (an out-of-range value also fails and is then
// treated as free text). This lets "2438" resolve to an exact number match in
// addition to the free-text branch.
func parseIssueNumber(q string) (int, bool) {
q = strings.TrimSpace(q)
if !issueNumberRe.MatchString(q) {
return 0, false
}
n, err := strconv.Atoi(q)
if err != nil {
return 0, false
}
return n, true
}
// searchableContentComparator matches free text across an issue's title and
// description. It returns Linear's ContentComparator value (the inner map) that
// callers nest under the `searchableContent` key — not the full filter. Its one
// text operator, `contains`, is already case-insensitive full-text; the
// ContentComparator deliberately does NOT expose `containsIgnoreCase` (sending
// that field makes Linear reject the whole query with BAD_USER_INPUT / 400).
func searchableContentComparator(q string) map[string]interface{} {
return map[string]interface{}{"contains": q}
}
// applyQueryFilter translates the free-text portion of a search into Linear's
// IssueFilter. `searchableContent` matches across title and description but
// never indexes the issue identifier or number, so when the query looks like a
// ticket reference (ENG-123, or a bare number like 2438) we OR in an exact
// match — otherwise searching by ID/number returns zero hits for the target
// ticket. The searchableContent branch stays in the OR so cross-references like
// "duplicate of ENG-123" pasted into another issue's body still surface.
func applyQueryFilter(out map[string]interface{}, q string) {
content := searchableContentComparator(q)
if teamKey, num, ok := parseIssueIdentifier(q); ok {
out["or"] = []map[string]interface{}{
{"searchableContent": content},
{
"team": map[string]interface{}{"key": map[string]interface{}{"eq": teamKey}},
"number": map[string]interface{}{"eq": num},
},
}
return
}
if num, ok := parseIssueNumber(q); ok {
out["or"] = []map[string]interface{}{
{"searchableContent": content},
{"number": map[string]interface{}{"eq": num}},
}
return
}
out["searchableContent"] = content
}
// 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 != "" {
applyQueryFilter(out, 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(f.Priorities) > 0 {
out["priority"] = map[string]interface{}{"in": f.Priorities}
}
if len(f.LabelIDs) > 0 {
// `labels.some` matches issues having at least one label whose id is
// in the provided list — Linear's OR semantics for multi-label filter.
out["labels"] = map[string]interface{}{
"some": map[string]interface{}{
"id": map[string]interface{}{"in": f.LabelIDs},
},
}
}
if f.CreatorID != "" {
out["creator"] = map[string]interface{}{"id": map[string]interface{}{"eq": f.CreatorID}}
}
if f.EstimateMin != nil || f.EstimateMax != nil {
bounds := map[string]interface{}{}
if f.EstimateMin != nil {
bounds["gte"] = *f.EstimateMin
}
if f.EstimateMax != nil {
bounds["lte"] = *f.EstimateMax
}
out["estimate"] = bounds
}
if len(out) == 0 {
return nil
}
return out
}
// --- labels ---
// Linear's GraphQL API caps `first` at 250. We don't paginate labels/users
// because the watcher dialog renders them as a single dropdown — a workspace
// with >250 labels or members would have UX problems regardless of how the
// data is fetched. If this limit is hit in practice, switch to cursor-based
// pagination here and lazy-load in the UI.
const linearMaxPageSize = 250
const teamLabelsQuery = `
query TeamLabels($filter: IssueLabelFilter!, $first: Int!) {
issueLabels(first: $first, filter: $filter) {
nodes { id name color }
}
}`
type labelsData struct {
IssueLabels struct {
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
} `json:"nodes"`
} `json:"issueLabels"`
}
// ListLabels returns the issue labels available for a team identified by its
// key. Workspace-scoped labels (no team) are included by also fetching the
// `team: null` set in a second pass when teamKey is empty.
func (c *GraphQLClient) ListLabels(ctx context.Context, teamKey string) ([]LinearLabel, 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}},
},
"first": linearMaxPageSize,
}
var data labelsData
if err := c.do(ctx, teamLabelsQuery, vars, &data); err != nil {
return nil, err
}
out := make([]LinearLabel, 0, len(data.IssueLabels.Nodes))
for _, l := range data.IssueLabels.Nodes {
out = append(out, LinearLabel{ID: l.ID, Name: l.Name, Color: l.Color})
}
return out, nil
}
// --- users ---
const teamMembersQuery = `
query TeamMembers($filter: UserFilter!, $first: Int!) {
users(first: $first, filter: $filter, orderBy: updatedAt) {
nodes { id name displayName email avatarUrl }
}
}`
type usersData struct {
Users struct {
Nodes []struct {
ID string `json:"id"`
Name string `json:"name"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
AvatarURL string `json:"avatarUrl"`
} `json:"nodes"`
} `json:"users"`
}
// ListUsers returns members of the team identified by teamKey. The filter
// uses Linear's `teamMemberships.some` to scope to that team.
func (c *GraphQLClient) ListUsers(ctx context.Context, teamKey string) ([]LinearUser, error) {
if teamKey == "" {
return nil, fmt.Errorf("teamKey required")
}
vars := map[string]interface{}{
"filter": map[string]interface{}{
"teamMemberships": map[string]interface{}{
"some": map[string]interface{}{
"team": map[string]interface{}{"key": map[string]interface{}{"eq": teamKey}},
},
},
},
"first": linearMaxPageSize,
}
var data usersData
if err := c.do(ctx, teamMembersQuery, vars, &data); err != nil {
return nil, err
}
out := make([]LinearUser, 0, len(data.Users.Nodes))
for _, u := range data.Users.Nodes {
out = append(out, LinearUser{
ID: u.ID,
Name: u.Name,
DisplayName: u.DisplayName,
Email: u.Email,
AvatarURL: u.AvatarURL,
})
}
return out, nil
}