Class: LinearApi::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/linear_api/client.rb

Overview

GraphQL client for Linear API

Constant Summary collapse

API_ENDPOINT =
'https://api.linear.app/graphql'
DEFAULT_OPEN_TIMEOUT =

Default timeouts (seconds)

5
DEFAULT_READ_TIMEOUT =
15
DEFAULT_MAX_RETRIES =

Retry configuration

3
DEFAULT_RETRY_INTERVAL =
0.5

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(api_key:, team_id: nil, open_timeout: nil, read_timeout: nil, max_retries: nil) ⇒ Client

Returns a new instance of Client.



22
23
24
25
26
27
28
# File 'lib/linear_api/client.rb', line 22

def initialize(api_key:, team_id: nil, open_timeout: nil, read_timeout: nil, max_retries: nil)
  @api_key = api_key
  @team_id = team_id
  @open_timeout = open_timeout || DEFAULT_OPEN_TIMEOUT
  @read_timeout = read_timeout || DEFAULT_READ_TIMEOUT
  @max_retries = max_retries || DEFAULT_MAX_RETRIES
end

Instance Attribute Details

#api_keyObject (readonly)

Returns the value of attribute api_key.



20
21
22
# File 'lib/linear_api/client.rb', line 20

def api_key
  @api_key
end

#team_idObject (readonly)

Returns the value of attribute team_id.



20
21
22
# File 'lib/linear_api/client.rb', line 20

def team_id
  @team_id
end

Instance Method Details

#add_comment(issue_id:, body:) ⇒ Result

Add a comment to an issue

Parameters:

  • issue_id (String)

    Issue ID

  • body (String)

    Comment body (markdown)

Returns:

  • (Result)

    Result with comment



193
194
195
196
197
198
199
200
201
202
# File 'lib/linear_api/client.rb', line 193

def add_comment(issue_id:, body:)
  result = query(Issue::ADD_COMMENT_MUTATION, variables: { issueId: issue_id, body: body })
  return result if result.failure?

  Result.new(
    success: result.data.dig('commentCreate', 'success'),
    data: result.data.dig('commentCreate', 'comment'),
    raw_response: result.raw_response
  )
end

#add_labels(id:, label_ids:) ⇒ Result

Add labels to an issue (appends to existing)

Parameters:

  • id (String)

    Issue ID

  • label_ids (Array<String>)

    Label IDs to add

Returns:

  • (Result)

    Result with updated issue



302
303
304
305
306
307
308
309
310
# File 'lib/linear_api/client.rb', line 302

def add_labels(id:, label_ids:)
  issue_result = get_issue_by_id(id)
  return issue_result if issue_result.failure?

  current_ids = issue_result.data.labels.map { |l| l['id'] }
  new_ids = (current_ids + label_ids).uniq

  update_issue(id: id, label_ids: new_ids)
end

#archive_issue(id:) ⇒ Result

Archive an issue (soft delete)

Parameters:

  • id (String)

    Issue ID

Returns:

  • (Result)

    Result with success status



236
237
238
239
240
241
242
243
244
245
# File 'lib/linear_api/client.rb', line 236

def archive_issue(id:)
  result = query(Issue::ARCHIVE_MUTATION, variables: { id: id })
  return result if result.failure?

  Result.new(
    success: result.data.dig('issueArchive', 'success'),
    data: { archived: true },
    raw_response: result.raw_response
  )
end

#assign(id:, assignee_id:) ⇒ Result

Assign issue to a user

Parameters:

  • id (String)

    Issue ID

  • assignee_id (String)

    User ID (nil to unassign)

Returns:

  • (Result)

    Result with updated issue



332
333
334
# File 'lib/linear_api/client.rb', line 332

def assign(id:, assignee_id:)
  update_issue(id: id, assignee_id: assignee_id)
end

#batch_get_issues(ids:) ⇒ Result

Batch fetch issues by IDs (avoids N+1 API calls)

Parameters:

  • ids (Array<String>)

    Issue UUIDs

Returns:

  • (Result)

    Result with array of issues



224
225
226
227
228
229
230
# File 'lib/linear_api/client.rb', line 224

def batch_get_issues(ids:)
  result = query(Issue::BATCH_GET_QUERY, variables: { filter: { id: { in: ids } } })
  return result if result.failure?

  issues = (result.data.dig('issues', 'nodes') || []).map { |i| Issue.new(i) }
  Result.new(success: true, data: issues, raw_response: result.raw_response)
end

#create_issue(title:, description: nil, **options) ⇒ Result

Create a new issue

Parameters:

  • title (String)

    Issue title

  • description (String) (defaults to: nil)

    Issue description (markdown)

  • options (Hash)

    Additional options (priority, label_ids, project_id, etc.)

Returns:

  • (Result)

    Result with created issue



63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/linear_api/client.rb', line 63

def create_issue(title:, description: nil, **options)
  input = build_issue_input(title: title, description: description, **options)

  result = query(Issue::CREATE_MUTATION, variables: { input: input })
  return result if result.failure?

  issue_data = result.data.dig('issueCreate', 'issue')
  Result.new(
    success: result.data.dig('issueCreate', 'success'),
    data: Issue.new(issue_data),
    raw_response: result.raw_response
  )
end

#create_label(name:, color: nil, description: nil) ⇒ Result

Create a new label

Parameters:

  • name (String)

    Label name (e.g., "type:bug", "area:sync")

  • color (String) (defaults to: nil)

    Hex color (e.g., "#ff0000")

  • description (String) (defaults to: nil)

    Optional description

Returns:

  • (Result)

    Result with created label



464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
# File 'lib/linear_api/client.rb', line 464

def create_label(name:, color: nil, description: nil)
  input = { name: name, teamId: team_id }
  input[:color] = color if color
  input[:description] = description if description

  result = query(Label::CREATE_MUTATION, variables: { input: input })
  return result if result.failure?

  label_data = result.data.dig('issueLabelCreate', 'issueLabel')
  Result.new(
    success: result.data.dig('issueLabelCreate', 'success'),
    data: Label.new(label_data),
    raw_response: result.raw_response
  )
end

#create_project(name:, description: nil, color: nil) ⇒ Result

Create a new project

Parameters:

  • name (String)

    Project name

  • description (String) (defaults to: nil)

    Optional project description

  • color (String) (defaults to: nil)

    Optional hex color (e.g., "#0052CC")

Returns:

  • (Result)

    Result with created project



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# File 'lib/linear_api/client.rb', line 427

def create_project(name:, description: nil, color: nil)
  input = { name: name, teamIds: [team_id] }
  input[:description] = description if description
  input[:color] = color if color

  result = query(Project::CREATE_MUTATION, variables: { input: input })
  return result if result.failure?

  project_data = result.data.dig('projectCreate', 'project')
  Result.new(
    success: result.data.dig('projectCreate', 'success'),
    data: Project.new(project_data),
    raw_response: result.raw_response
  )
end

#create_sub_issue(parent_id:, title:, **options) ⇒ Result

Create a sub-issue (child issue)

Parameters:

  • parent_id (String)

    Parent issue ID

  • title (String)

    Sub-issue title

  • options (Hash)

    Additional options

Returns:

  • (Result)

    Result with created sub-issue



268
269
270
# File 'lib/linear_api/client.rb', line 268

def create_sub_issue(parent_id:, title:, **options)
  create_issue(title: title, parent_id: parent_id, **options)
end

#fetch_all_metadataResult

Fetch all team metadata in a single API call (labels, projects, states, users)

Returns:

  • (Result)

    Result with hash of { labels:, projects:, states:, users: }



392
393
394
395
396
397
398
399
400
401
402
403
404
# File 'lib/linear_api/client.rb', line 392

def 
  result = query(Team::ALL_METADATA_QUERY, variables: { teamId: team_id })
  return result if result.failure?

  team = result.data['team']
  data = {
    labels: (team.dig('labels', 'nodes') || []).map { |l| Label.new(l) },
    projects: (team.dig('projects', 'nodes') || []).map { |p| Project.new(p) },
    states: team.dig('states', 'nodes') || [],
    users: team.dig('members', 'nodes') || []
  }
  Result.new(success: true, data: data, raw_response: result.raw_response)
end

#get_issue(identifier) ⇒ Result

Get issue by identifier (e.g., "TOS-123") using direct filter query

Parses the identifier into team key and issue number, then filters using the number and team.key fields on IssueFilter (Linear's GraphQL schema does not expose an identifier filter).

Parameters:

  • identifier (String)

    Issue identifier (e.g., "TOS-123")

Returns:

  • (Result)

    Result with issue



104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/linear_api/client.rb', line 104

def get_issue(identifier)
  team_key, number = parse_identifier(identifier)
  unless team_key && number
    return Result.new(success: false, error: "Invalid identifier format: #{identifier} (expected TEAM-123)")
  end

  filter = {
    number: { eq: number },
    team: { key: { eq: team_key } }
  }

  result = query(Issue::GET_BY_IDENTIFIER_QUERY, variables: { filter: filter })
  return result if result.failure?

  issues = result.data.dig('issues', 'nodes') || []
  if issues.empty?
    Result.new(success: false, error: "Issue not found: #{identifier}")
  else
    Result.new(success: true, data: Issue.new(issues.first), raw_response: result.raw_response)
  end
end

#get_issue_by_id(id) ⇒ Result

Get issue by UUID (not identifier)

Parameters:

  • id (String)

    Issue UUID

Returns:

  • (Result)

    Result with issue



208
209
210
211
212
213
214
215
216
217
218
# File 'lib/linear_api/client.rb', line 208

def get_issue_by_id(id)
  result = query(Issue::GET_BY_ID_QUERY, variables: { id: id })
  return result if result.failure?

  issue_data = result.data['issue']
  if issue_data.nil?
    Result.new(success: false, error: "Issue not found: #{id}")
  else
    Result.new(success: true, data: Issue.new(issue_data), raw_response: result.raw_response)
  end
end

#get_teamResult

Get team info

Returns:

  • (Result)

    Result with team data



359
360
361
362
363
364
365
# File 'lib/linear_api/client.rb', line 359

def get_team
  result = query(Team::GET_QUERY, variables: { teamId: team_id })
  return result if result.failure?

  team_data = result.data['team']
  Result.new(success: true, data: Team.new(team_data), raw_response: result.raw_response)
end

Link a GitHub PR to an issue

Parameters:

  • issue_id (String)

    Issue ID

  • pr_url (String)

    GitHub PR URL

Returns:

  • (Result)

    Result with attachment



341
342
343
344
345
346
347
348
349
350
# File 'lib/linear_api/client.rb', line 341

def link_pr(issue_id:, pr_url:)
  result = query(Issue::LINK_PR_MUTATION, variables: { issueId: issue_id, url: pr_url })
  return result if result.failure?

  Result.new(
    success: result.data.dig('attachmentLinkURL', 'success'),
    data: result.data.dig('attachmentLinkURL', 'attachment'),
    raw_response: result.raw_response
  )
end

#list_all_issues(filter: nil, batch_size: 50) {|Issue| ... } ⇒ Result

List all issues with automatic pagination

Parameters:

  • filter (Hash) (defaults to: nil)

    Filter options

  • batch_size (Integer) (defaults to: 50)

    Items per page

Yields:

  • (Issue)

    Each issue as it's fetched (optional)

Returns:

  • (Result)

    Result with array of all issues



161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
# File 'lib/linear_api/client.rb', line 161

def list_all_issues(filter: nil, batch_size: 50, &block)
  all_issues = []
  cursor = nil

  loop do
    variables = { teamId: team_id, first: batch_size }
    variables[:filter] = filter if filter
    variables[:after] = cursor if cursor

    result = query(Issue::LIST_PAGINATED_QUERY, variables: variables)
    return result if result.failure?

    nodes = (result.data.dig('team', 'issues', 'nodes') || []).map { |i| Issue.new(i) }
    nodes.each do |issue|
      block&.call(issue)
      all_issues << issue
    end

    page_info = result.data.dig('team', 'issues', 'pageInfo')
    break unless page_info&.dig('hasNextPage')

    cursor = page_info['endCursor']
  end

  Result.new(success: true, data: all_issues)
end

#list_issues(filter: nil, limit: 50) ⇒ Result

List issues for the team

Parameters:

  • filter (Hash) (defaults to: nil)

    Filter options

  • limit (Integer) (defaults to: 50)

    Max issues to return

Returns:

  • (Result)

    Result with array of issues



144
145
146
147
148
149
150
151
152
153
# File 'lib/linear_api/client.rb', line 144

def list_issues(filter: nil, limit: 50)
  variables = { teamId: team_id, first: limit }
  variables[:filter] = filter if filter

  result = query(Issue::LIST_QUERY, variables: variables)
  return result if result.failure?

  issues = (result.data.dig('team', 'issues', 'nodes') || []).map { |i| Issue.new(i) }
  Result.new(success: true, data: issues, raw_response: result.raw_response)
end

#list_labelsResult

List labels for the team

Returns:

  • (Result)

    Result with array of labels



450
451
452
453
454
455
456
# File 'lib/linear_api/client.rb', line 450

def list_labels
  result = query(Label::LIST_QUERY, variables: { teamId: team_id })
  return result if result.failure?

  labels = (result.data.dig('team', 'labels', 'nodes') || []).map { |l| Label.new(l) }
  Result.new(success: true, data: labels, raw_response: result.raw_response)
end

#list_projectsResult

List projects for the team

Returns:

  • (Result)

    Result with array of projects



413
414
415
416
417
418
419
# File 'lib/linear_api/client.rb', line 413

def list_projects
  result = query(Project::LIST_QUERY, variables: { teamId: team_id })
  return result if result.failure?

  projects = (result.data.dig('team', 'projects', 'nodes') || []).map { |p| Project.new(p) }
  Result.new(success: true, data: projects, raw_response: result.raw_response)
end

#list_statesResult

Get workflow states for the team

Returns:

  • (Result)

    Result with array of states



370
371
372
373
374
375
376
# File 'lib/linear_api/client.rb', line 370

def list_states
  result = query(Team::STATES_QUERY, variables: { teamId: team_id })
  return result if result.failure?

  states = result.data.dig('team', 'states', 'nodes') || []
  Result.new(success: true, data: states, raw_response: result.raw_response)
end

#list_sub_issues(parent_id:) ⇒ Result

Get sub-issues for an issue

Parameters:

  • parent_id (String)

    Parent issue ID

Returns:

  • (Result)

    Result with array of sub-issues



276
277
278
279
280
281
282
# File 'lib/linear_api/client.rb', line 276

def list_sub_issues(parent_id:)
  result = query(Issue::SUB_ISSUES_QUERY, variables: { id: parent_id })
  return result if result.failure?

  children = (result.data.dig('issue', 'children', 'nodes') || []).map { |i| Issue.new(i) }
  Result.new(success: true, data: children, raw_response: result.raw_response)
end

#list_usersResult

List team members for assignment

Returns:

  • (Result)

    Result with array of users



381
382
383
384
385
386
387
# File 'lib/linear_api/client.rb', line 381

def list_users
  result = query(Team::USERS_QUERY, variables: { teamId: team_id })
  return result if result.failure?

  users = result.data.dig('team', 'members', 'nodes') || []
  Result.new(success: true, data: users, raw_response: result.raw_response)
end

#move_to_state(id:, state_id:) ⇒ Result

Move an issue to a different state

Parameters:

  • id (String)

    Issue ID

  • state_id (String)

    Target state ID

Returns:

  • (Result)

    Result with updated issue



293
294
295
# File 'lib/linear_api/client.rb', line 293

def move_to_state(id:, state_id:)
  update_issue(id: id, state_id: state_id)
end

#query(query_str, variables: {}) ⇒ Result

Execute a GraphQL query

Parameters:

  • query (String)

    GraphQL query string

  • variables (Hash) (defaults to: {})

    Query variables

Returns:

  • (Result)

    Result object with data or error



35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# File 'lib/linear_api/client.rb', line 35

def query(query_str, variables: {})
  started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)

  LinearApi.logger.debug { "LinearApi: executing query #{query_str.lines.first&.strip}" }

  response = connection.post do |req|
    req.body = { query: query_str, variables: variables }.to_json
  end

  elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
  LinearApi.logger.debug { "LinearApi: response #{response.status} in #{elapsed}ms" }

  handle_response(response)
rescue Faraday::Error => e
  LinearApi.logger.error { "LinearApi: network error: #{e.class} - #{e.message}" }
  Result.new(success: false, error: "Network error: #{e.message}")
end

#remove_labels(id:, label_ids:) ⇒ Result

Remove labels from an issue

Parameters:

  • id (String)

    Issue ID

  • label_ids (Array<String>)

    Label IDs to remove

Returns:

  • (Result)

    Result with updated issue



317
318
319
320
321
322
323
324
325
# File 'lib/linear_api/client.rb', line 317

def remove_labels(id:, label_ids:)
  issue_result = get_issue_by_id(id)
  return issue_result if issue_result.failure?

  current_ids = issue_result.data.labels.map { |l| l['id'] }
  new_ids = current_ids - label_ids

  update_issue(id: id, label_ids: new_ids)
end

#search_issues(term:, limit: 10) ⇒ Result

Search issues by text query (full-text search)

Parameters:

  • term (String)

    Search term

  • limit (Integer) (defaults to: 10)

    Max results

Returns:

  • (Result)

    Result with array of issues



131
132
133
134
135
136
137
# File 'lib/linear_api/client.rb', line 131

def search_issues(term:, limit: 10)
  result = query(Issue::SEARCH_QUERY, variables: { term: term, first: limit })
  return result if result.failure?

  issues = (result.data.dig('searchIssues', 'nodes') || []).map { |i| Issue.new(i) }
  Result.new(success: true, data: issues, raw_response: result.raw_response)
end

#unarchive_issue(id:) ⇒ Result

Unarchive an issue

Parameters:

  • id (String)

    Issue ID

Returns:

  • (Result)

    Result with success status



251
252
253
254
255
256
257
258
259
260
# File 'lib/linear_api/client.rb', line 251

def unarchive_issue(id:)
  result = query(Issue::UNARCHIVE_MUTATION, variables: { id: id })
  return result if result.failure?

  Result.new(
    success: result.data.dig('issueUnarchive', 'success'),
    data: { archived: false },
    raw_response: result.raw_response
  )
end

#update_issue(id:, **options) ⇒ Result

Update an existing issue

Parameters:

  • id (String)

    Issue ID

  • options (Hash)

    Fields to update

Returns:

  • (Result)

    Result with updated issue



82
83
84
85
86
87
88
89
90
91
92
93
94
# File 'lib/linear_api/client.rb', line 82

def update_issue(id:, **options)
  input = options.transform_keys { |k| camelize(k) }

  result = query(Issue::UPDATE_MUTATION, variables: { id: id, input: input })
  return result if result.failure?

  issue_data = result.data.dig('issueUpdate', 'issue')
  Result.new(
    success: result.data.dig('issueUpdate', 'success'),
    data: Issue.new(issue_data),
    raw_response: result.raw_response
  )
end