Files
kandev/apps/backend/internal/linear/graphql_client.go
T
João Salavisa fc01fa37cb fix(linear): match issue identifier (ENG-123) in search (#1340)
* fix(linear): match issue identifier (ENG-123) in search

Linear's `searchableContent` filter never matches the issue identifier
(team key + number), so a query like `ENG-123` from the Linear view
returned no results. When the query parses as a `TEAM-NUM` identifier,
OR-combine the content match with `{team.key.eq, number.eq}` so the
exact issue surfaces while other filters keep AND-joining. Lowercase
inputs (`eng-123`) are normalized to the upper-case team key.

Fixes #1339

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(linear): mention ID in search box placeholder

Reflect the backend's new identifier-aware search by hinting at it in
the search input on `/linear`.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs(linear): explain why identifier search keeps both OR branches

claude[bot] flagged the searchableContent branch in the identifier OR
as functionally dead and the surrounding comment as a sentence fragment.
The branch is intentional — it catches cross-references like "duplicate
of ENG-123" pasted into another issue's title or description, which the
exact team+number branch would miss. Rewrite the comment to explain
that, fixing the fragment in the same change.

* fix(linear): use canonical identifier form in searchableContent OR branch

greptile flagged that the `searchableContent.contains` branch in the
identifier OR was sent verbatim from user input. When the user typed
`eng-123`, Linear's case-sensitive `contains` filter would miss
cross-references written in the canonical form `ENG-123`. Normalise
the contains value to the same upper-cased identifier we use for the
exact `team+number` branch, and lock that behaviour in
`TestBuildIssueFilter_IdentifierLowercase`.

* fix(linear): use containsIgnoreCase for searchableContent

@carlosflorencio flagged that canonicalising the identifier OR branch
to upper case meant lowercase literal references (e.g. "see eng-123"
pasted into a description) no longer matched. The previous round had
swapped the other way at greptile's request to fix uppercase
references. Both directions are right — the underlying issue is that
Linear's `contains` filter is case-sensitive. Switch the whole
`searchableContent` filter (identifier OR branch and the non-identifier
free-text path) to `containsIgnoreCase`, so casing of the user input
and of the indexed text no longer matters in either direction.

Free-text search is now case-insensitive too, which is the intuitive
default for a search box.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-11 19:15:52 +01:00

711 lines
21 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
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
}
// 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
}
// 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. When the query looks like a
// ticket identifier (ENG-123) we OR in an exact team+number branch,
// because `searchableContent` never indexes the identifier itself —
// without the extra branch, searching by ID would return zero hits
// for the target ticket. We keep the `searchableContent` branch in
// the OR so cross-references like "duplicate of ENG-123" pasted into
// another issue's title or description still surface.
// `containsIgnoreCase` so identifier cross-references ("see ENG-123")
// match regardless of the user's input casing, and so free-text
// queries behave intuitively case-insensitively.
if teamKey, num, ok := parseIssueIdentifier(q); ok {
out["or"] = []map[string]interface{}{
{"searchableContent": map[string]interface{}{"containsIgnoreCase": q}},
{
"team": map[string]interface{}{"key": map[string]interface{}{"eq": teamKey}},
"number": map[string]interface{}{"eq": num},
},
}
} else {
out["searchableContent"] = map[string]interface{}{"containsIgnoreCase": 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
}