linear_api 0.3.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'faraday'
4
+ require 'faraday/retry'
4
5
  require 'json'
5
6
 
6
7
  module LinearApi
@@ -8,11 +9,22 @@ module LinearApi
8
9
  class Client
9
10
  API_ENDPOINT = 'https://api.linear.app/graphql'
10
11
 
12
+ # Default timeouts (seconds)
13
+ DEFAULT_OPEN_TIMEOUT = 5
14
+ DEFAULT_READ_TIMEOUT = 15
15
+
16
+ # Retry configuration
17
+ DEFAULT_MAX_RETRIES = 3
18
+ DEFAULT_RETRY_INTERVAL = 0.5
19
+
11
20
  attr_reader :api_key, :team_id
12
21
 
13
- def initialize(api_key:, team_id: nil)
22
+ def initialize(api_key:, team_id: nil, open_timeout: nil, read_timeout: nil, max_retries: nil)
14
23
  @api_key = api_key
15
24
  @team_id = team_id
25
+ @open_timeout = open_timeout || DEFAULT_OPEN_TIMEOUT
26
+ @read_timeout = read_timeout || DEFAULT_READ_TIMEOUT
27
+ @max_retries = max_retries || DEFAULT_MAX_RETRIES
16
28
  end
17
29
 
18
30
  # Execute a GraphQL query
@@ -20,16 +32,28 @@ module LinearApi
20
32
  # @param query [String] GraphQL query string
21
33
  # @param variables [Hash] Query variables
22
34
  # @return [Result] Result object with data or error
23
- def query(query, variables: {})
35
+ def query(query_str, variables: {})
36
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
+
38
+ LinearApi.logger.debug { "LinearApi: executing query #{query_str.lines.first&.strip}" }
39
+
24
40
  response = connection.post do |req|
25
- req.body = { query: query, variables: variables }.to_json
41
+ req.body = { query: query_str, variables: variables }.to_json
26
42
  end
27
43
 
44
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
45
+ LinearApi.logger.debug { "LinearApi: response #{response.status} in #{elapsed}ms" }
46
+
28
47
  handle_response(response)
29
48
  rescue Faraday::Error => e
49
+ LinearApi.logger.error { "LinearApi: network error: #{e.class} - #{e.message}" }
30
50
  Result.new(success: false, error: "Network error: #{e.message}")
31
51
  end
32
52
 
53
+ # ==========================================================================
54
+ # Issue Operations
55
+ # ==========================================================================
56
+
33
57
  # Create a new issue
34
58
  #
35
59
  # @param title [String] Issue title
@@ -69,15 +93,15 @@ module LinearApi
69
93
  )
70
94
  end
71
95
 
72
- # Get issue by identifier (e.g., "TOS-123")
96
+ # Get issue by identifier (e.g., "TOS-123") using direct filter query
73
97
  #
74
98
  # @param identifier [String] Issue identifier
75
99
  # @return [Result] Result with issue
76
100
  def get_issue(identifier)
77
- result = query(Issue::SEARCH_QUERY, variables: { term: identifier })
101
+ result = query(Issue::GET_BY_IDENTIFIER_QUERY, variables: { filter: { identifier: { eq: identifier } } })
78
102
  return result if result.failure?
79
103
 
80
- issues = result.data.dig('searchIssues', 'nodes') || []
104
+ issues = result.data.dig('issues', 'nodes') || []
81
105
  if issues.empty?
82
106
  Result.new(success: false, error: "Issue not found: #{identifier}")
83
107
  else
@@ -85,6 +109,19 @@ module LinearApi
85
109
  end
86
110
  end
87
111
 
112
+ # Search issues by text query (full-text search)
113
+ #
114
+ # @param term [String] Search term
115
+ # @param limit [Integer] Max results
116
+ # @return [Result] Result with array of issues
117
+ def search_issues(term:, limit: 10)
118
+ result = query(Issue::SEARCH_QUERY, variables: { term: term, first: limit })
119
+ return result if result.failure?
120
+
121
+ issues = (result.data.dig('searchIssues', 'nodes') || []).map { |i| Issue.new(i) }
122
+ Result.new(success: true, data: issues, raw_response: result.raw_response)
123
+ end
124
+
88
125
  # List issues for the team
89
126
  #
90
127
  # @param filter [Hash] Filter options
@@ -101,6 +138,39 @@ module LinearApi
101
138
  Result.new(success: true, data: issues, raw_response: result.raw_response)
102
139
  end
103
140
 
141
+ # List all issues with automatic pagination
142
+ #
143
+ # @param filter [Hash] Filter options
144
+ # @param batch_size [Integer] Items per page
145
+ # @yield [Issue] Each issue as it's fetched (optional)
146
+ # @return [Result] Result with array of all issues
147
+ def list_all_issues(filter: nil, batch_size: 50, &block)
148
+ all_issues = []
149
+ cursor = nil
150
+
151
+ loop do
152
+ variables = { teamId: team_id, first: batch_size }
153
+ variables[:filter] = filter if filter
154
+ variables[:after] = cursor if cursor
155
+
156
+ result = query(Issue::LIST_PAGINATED_QUERY, variables: variables)
157
+ return result if result.failure?
158
+
159
+ nodes = (result.data.dig('team', 'issues', 'nodes') || []).map { |i| Issue.new(i) }
160
+ nodes.each do |issue|
161
+ block&.call(issue)
162
+ all_issues << issue
163
+ end
164
+
165
+ page_info = result.data.dig('team', 'issues', 'pageInfo')
166
+ break unless page_info&.dig('hasNextPage')
167
+
168
+ cursor = page_info['endCursor']
169
+ end
170
+
171
+ Result.new(success: true, data: all_issues)
172
+ end
173
+
104
174
  # Add a comment to an issue
105
175
  #
106
176
  # @param issue_id [String] Issue ID
@@ -117,89 +187,88 @@ module LinearApi
117
187
  )
118
188
  end
119
189
 
120
- # Get team info
190
+ # Get issue by UUID (not identifier)
121
191
  #
122
- # @return [Result] Result with team data
123
- def get_team
124
- result = query(Team::GET_QUERY, variables: { teamId: team_id })
192
+ # @param id [String] Issue UUID
193
+ # @return [Result] Result with issue
194
+ def get_issue_by_id(id)
195
+ result = query(Issue::GET_BY_ID_QUERY, variables: { id: id })
125
196
  return result if result.failure?
126
197
 
127
- team_data = result.data['team']
128
- Result.new(success: true, data: Team.new(team_data), raw_response: result.raw_response)
198
+ issue_data = result.data['issue']
199
+ if issue_data.nil?
200
+ Result.new(success: false, error: "Issue not found: #{id}")
201
+ else
202
+ Result.new(success: true, data: Issue.new(issue_data), raw_response: result.raw_response)
203
+ end
129
204
  end
130
205
 
131
- # List projects for the team
206
+ # Batch fetch issues by IDs (avoids N+1 API calls)
132
207
  #
133
- # @return [Result] Result with array of projects
134
- def list_projects
135
- result = query(Project::LIST_QUERY, variables: { teamId: team_id })
208
+ # @param ids [Array<String>] Issue UUIDs
209
+ # @return [Result] Result with array of issues
210
+ def batch_get_issues(ids:)
211
+ result = query(Issue::BATCH_GET_QUERY, variables: { filter: { id: { in: ids } } })
136
212
  return result if result.failure?
137
213
 
138
- projects = (result.data.dig('team', 'projects', 'nodes') || []).map { |p| Project.new(p) }
139
- Result.new(success: true, data: projects, raw_response: result.raw_response)
214
+ issues = (result.data.dig('issues', 'nodes') || []).map { |i| Issue.new(i) }
215
+ Result.new(success: true, data: issues, raw_response: result.raw_response)
140
216
  end
141
217
 
142
- # Create a new project
218
+ # Archive an issue (soft delete)
143
219
  #
144
- # @param name [String] Project name
145
- # @param description [String] Optional project description
146
- # @param color [String] Optional hex color (e.g., "#0052CC")
147
- # @return [Result] Result with created project
148
- def create_project(name:, description: nil, color: nil)
149
- create_project_mutation = <<~GRAPHQL
150
- mutation CreateProject($input: ProjectCreateInput!) {
151
- projectCreate(input: $input) {
152
- success
153
- project {
154
- id
155
- name
156
- description
157
- url
158
- color
159
- }
160
- }
161
- }
162
- GRAPHQL
163
-
164
- input = { name: name, teamIds: [team_id] }
165
- input[:description] = description if description
166
- input[:color] = color if color
167
-
168
- result = query(create_project_mutation, variables: { input: input })
220
+ # @param id [String] Issue ID
221
+ # @return [Result] Result with success status
222
+ def archive_issue(id:)
223
+ result = query(Issue::ARCHIVE_MUTATION, variables: { id: id })
169
224
  return result if result.failure?
170
225
 
171
- project_data = result.data.dig('projectCreate', 'project')
172
226
  Result.new(
173
- success: result.data.dig('projectCreate', 'success'),
174
- data: Project.new(project_data),
227
+ success: result.data.dig('issueArchive', 'success'),
228
+ data: { archived: true },
175
229
  raw_response: result.raw_response
176
230
  )
177
231
  end
178
232
 
179
- # List labels for the team
233
+ # Unarchive an issue
180
234
  #
181
- # @return [Result] Result with array of labels
182
- def list_labels
183
- result = query(Label::LIST_QUERY, variables: { teamId: team_id })
235
+ # @param id [String] Issue ID
236
+ # @return [Result] Result with success status
237
+ def unarchive_issue(id:)
238
+ result = query(Issue::UNARCHIVE_MUTATION, variables: { id: id })
184
239
  return result if result.failure?
185
240
 
186
- labels = (result.data.dig('team', 'labels', 'nodes') || []).map { |l| Label.new(l) }
187
- Result.new(success: true, data: labels, raw_response: result.raw_response)
241
+ Result.new(
242
+ success: result.data.dig('issueUnarchive', 'success'),
243
+ data: { archived: false },
244
+ raw_response: result.raw_response
245
+ )
188
246
  end
189
247
 
190
- # Get workflow states for the team
248
+ # Create a sub-issue (child issue)
191
249
  #
192
- # @return [Result] Result with array of states
193
- def list_states
194
- result = query(Team::STATES_QUERY, variables: { teamId: team_id })
250
+ # @param parent_id [String] Parent issue ID
251
+ # @param title [String] Sub-issue title
252
+ # @param options [Hash] Additional options
253
+ # @return [Result] Result with created sub-issue
254
+ def create_sub_issue(parent_id:, title:, **options)
255
+ create_issue(title: title, parent_id: parent_id, **options)
256
+ end
257
+
258
+ # Get sub-issues for an issue
259
+ #
260
+ # @param parent_id [String] Parent issue ID
261
+ # @return [Result] Result with array of sub-issues
262
+ def list_sub_issues(parent_id:)
263
+ result = query(Issue::SUB_ISSUES_QUERY, variables: { id: parent_id })
195
264
  return result if result.failure?
196
265
 
197
- states = result.data.dig('team', 'states', 'nodes') || []
198
- Result.new(success: true, data: states, raw_response: result.raw_response)
266
+ children = (result.data.dig('issue', 'children', 'nodes') || []).map { |i| Issue.new(i) }
267
+ Result.new(success: true, data: children, raw_response: result.raw_response)
199
268
  end
200
269
 
201
270
  # ==========================================================================
202
- # Convenience Methods for Common Operations
271
+ # Convenience Methods for Common Issue Operations
203
272
  # ==========================================================================
204
273
 
205
274
  # Move an issue to a different state
@@ -217,7 +286,6 @@ module LinearApi
217
286
  # @param label_ids [Array<String>] Label IDs to add
218
287
  # @return [Result] Result with updated issue
219
288
  def add_labels(id:, label_ids:)
220
- # First get current labels
221
289
  issue_result = get_issue_by_id(id)
222
290
  return issue_result if issue_result.failure?
223
291
 
@@ -257,19 +325,7 @@ module LinearApi
257
325
  # @param pr_url [String] GitHub PR URL
258
326
  # @return [Result] Result with attachment
259
327
  def link_pr(issue_id:, pr_url:)
260
- mutation = <<~GRAPHQL
261
- mutation LinkPR($issueId: String!, $url: String!) {
262
- attachmentLinkURL(issueId: $issueId, url: $url) {
263
- success
264
- attachment {
265
- id
266
- url
267
- }
268
- }
269
- }
270
- GRAPHQL
271
-
272
- result = query(mutation, variables: { issueId: issue_id, url: pr_url })
328
+ result = query(Issue::LINK_PR_MUTATION, variables: { issueId: issue_id, url: pr_url })
273
329
  return result if result.failure?
274
330
 
275
331
  Result.new(
@@ -279,79 +335,112 @@ module LinearApi
279
335
  )
280
336
  end
281
337
 
338
+ # ==========================================================================
339
+ # Team Operations
340
+ # ==========================================================================
341
+
342
+ # Get team info
343
+ #
344
+ # @return [Result] Result with team data
345
+ def get_team
346
+ result = query(Team::GET_QUERY, variables: { teamId: team_id })
347
+ return result if result.failure?
348
+
349
+ team_data = result.data['team']
350
+ Result.new(success: true, data: Team.new(team_data), raw_response: result.raw_response)
351
+ end
352
+
353
+ # Get workflow states for the team
354
+ #
355
+ # @return [Result] Result with array of states
356
+ def list_states
357
+ result = query(Team::STATES_QUERY, variables: { teamId: team_id })
358
+ return result if result.failure?
359
+
360
+ states = result.data.dig('team', 'states', 'nodes') || []
361
+ Result.new(success: true, data: states, raw_response: result.raw_response)
362
+ end
363
+
282
364
  # List team members for assignment
283
365
  #
284
366
  # @return [Result] Result with array of users
285
367
  def list_users
286
- users_query = <<~GRAPHQL
287
- query ListUsers($teamId: String!) {
288
- team(id: $teamId) {
289
- members {
290
- nodes {
291
- id
292
- name
293
- email
294
- displayName
295
- active
296
- }
297
- }
298
- }
299
- }
300
- GRAPHQL
301
-
302
- result = query(users_query, variables: { teamId: team_id })
368
+ result = query(Team::USERS_QUERY, variables: { teamId: team_id })
303
369
  return result if result.failure?
304
370
 
305
371
  users = result.data.dig('team', 'members', 'nodes') || []
306
372
  Result.new(success: true, data: users, raw_response: result.raw_response)
307
373
  end
308
374
 
309
- # Archive an issue (soft delete)
375
+ # Fetch all team metadata in a single API call (labels, projects, states, users)
310
376
  #
311
- # @param id [String] Issue ID
312
- # @return [Result] Result with success status
313
- def archive_issue(id:)
314
- archive_mutation = <<~GRAPHQL
315
- mutation ArchiveIssue($id: String!) {
316
- issueArchive(id: $id) {
317
- success
318
- }
319
- }
320
- GRAPHQL
321
-
322
- result = query(archive_mutation, variables: { id: id })
377
+ # @return [Result] Result with hash of { labels:, projects:, states:, users: }
378
+ def fetch_all_metadata
379
+ result = query(Team::ALL_METADATA_QUERY, variables: { teamId: team_id })
323
380
  return result if result.failure?
324
381
 
325
- Result.new(
326
- success: result.data.dig('issueArchive', 'success'),
327
- data: { archived: true },
328
- raw_response: result.raw_response
329
- )
382
+ team = result.data['team']
383
+ data = {
384
+ labels: (team.dig('labels', 'nodes') || []).map { |l| Label.new(l) },
385
+ projects: (team.dig('projects', 'nodes') || []).map { |p| Project.new(p) },
386
+ states: team.dig('states', 'nodes') || [],
387
+ users: team.dig('members', 'nodes') || []
388
+ }
389
+ Result.new(success: true, data: data, raw_response: result.raw_response)
330
390
  end
331
391
 
332
- # Unarchive an issue
392
+ # ==========================================================================
393
+ # Project Operations
394
+ # ==========================================================================
395
+
396
+ # List projects for the team
333
397
  #
334
- # @param id [String] Issue ID
335
- # @return [Result] Result with success status
336
- def unarchive_issue(id:)
337
- unarchive_mutation = <<~GRAPHQL
338
- mutation UnarchiveIssue($id: String!) {
339
- issueUnarchive(id: $id) {
340
- success
341
- }
342
- }
343
- GRAPHQL
344
-
345
- result = query(unarchive_mutation, variables: { id: id })
398
+ # @return [Result] Result with array of projects
399
+ def list_projects
400
+ result = query(Project::LIST_QUERY, variables: { teamId: team_id })
401
+ return result if result.failure?
402
+
403
+ projects = (result.data.dig('team', 'projects', 'nodes') || []).map { |p| Project.new(p) }
404
+ Result.new(success: true, data: projects, raw_response: result.raw_response)
405
+ end
406
+
407
+ # Create a new project
408
+ #
409
+ # @param name [String] Project name
410
+ # @param description [String] Optional project description
411
+ # @param color [String] Optional hex color (e.g., "#0052CC")
412
+ # @return [Result] Result with created project
413
+ def create_project(name:, description: nil, color: nil)
414
+ input = { name: name, teamIds: [team_id] }
415
+ input[:description] = description if description
416
+ input[:color] = color if color
417
+
418
+ result = query(Project::CREATE_MUTATION, variables: { input: input })
346
419
  return result if result.failure?
347
420
 
421
+ project_data = result.data.dig('projectCreate', 'project')
348
422
  Result.new(
349
- success: result.data.dig('issueUnarchive', 'success'),
350
- data: { archived: false },
423
+ success: result.data.dig('projectCreate', 'success'),
424
+ data: Project.new(project_data),
351
425
  raw_response: result.raw_response
352
426
  )
353
427
  end
354
428
 
429
+ # ==========================================================================
430
+ # Label Operations
431
+ # ==========================================================================
432
+
433
+ # List labels for the team
434
+ #
435
+ # @return [Result] Result with array of labels
436
+ def list_labels
437
+ result = query(Label::LIST_QUERY, variables: { teamId: team_id })
438
+ return result if result.failure?
439
+
440
+ labels = (result.data.dig('team', 'labels', 'nodes') || []).map { |l| Label.new(l) }
441
+ Result.new(success: true, data: labels, raw_response: result.raw_response)
442
+ end
443
+
355
444
  # Create a new label
356
445
  #
357
446
  # @param name [String] Label name (e.g., "type:bug", "area:sync")
@@ -359,25 +448,11 @@ module LinearApi
359
448
  # @param description [String] Optional description
360
449
  # @return [Result] Result with created label
361
450
  def create_label(name:, color: nil, description: nil)
362
- create_label_mutation = <<~GRAPHQL
363
- mutation CreateLabel($input: IssueLabelCreateInput!) {
364
- issueLabelCreate(input: $input) {
365
- success
366
- issueLabel {
367
- id
368
- name
369
- color
370
- description
371
- }
372
- }
373
- }
374
- GRAPHQL
375
-
376
451
  input = { name: name, teamId: team_id }
377
452
  input[:color] = color if color
378
453
  input[:description] = description if description
379
454
 
380
- result = query(create_label_mutation, variables: { input: input })
455
+ result = query(Label::CREATE_MUTATION, variables: { input: input })
381
456
  return result if result.failure?
382
457
 
383
458
  label_data = result.data.dig('issueLabelCreate', 'issueLabel')
@@ -388,99 +463,22 @@ module LinearApi
388
463
  )
389
464
  end
390
465
 
391
- # Create a sub-issue (child issue)
392
- #
393
- # @param parent_id [String] Parent issue ID
394
- # @param title [String] Sub-issue title
395
- # @param options [Hash] Additional options
396
- # @return [Result] Result with created sub-issue
397
- def create_sub_issue(parent_id:, title:, **options)
398
- create_issue(title: title, parent_id: parent_id, **options)
399
- end
400
-
401
- # Get sub-issues for an issue
402
- #
403
- # @param parent_id [String] Parent issue ID
404
- # @return [Result] Result with array of sub-issues
405
- def list_sub_issues(parent_id:)
406
- sub_issues_query = <<~GRAPHQL
407
- query GetSubIssues($id: String!) {
408
- issue(id: $id) {
409
- children {
410
- nodes {
411
- id
412
- identifier
413
- title
414
- state {
415
- id
416
- name
417
- }
418
- priority
419
- }
420
- }
421
- }
422
- }
423
- GRAPHQL
424
-
425
- result = query(sub_issues_query, variables: { id: parent_id })
426
- return result if result.failure?
427
-
428
- children = (result.data.dig('issue', 'children', 'nodes') || []).map { |i| Issue.new(i) }
429
- Result.new(success: true, data: children, raw_response: result.raw_response)
430
- end
431
-
432
- # Get issue by UUID (not identifier)
433
- #
434
- # @param id [String] Issue UUID
435
- # @return [Result] Result with issue
436
- def get_issue_by_id(id)
437
- issue_query = <<~GRAPHQL
438
- query GetIssueById($id: String!) {
439
- issue(id: $id) {
440
- id
441
- identifier
442
- title
443
- description
444
- url
445
- priority
446
- state {
447
- id
448
- name
449
- type
450
- }
451
- labels {
452
- nodes {
453
- id
454
- name
455
- }
456
- }
457
- assignee {
458
- id
459
- name
460
- }
461
- project {
462
- id
463
- name
464
- }
465
- }
466
- }
467
- GRAPHQL
468
-
469
- result = query(issue_query, variables: { id: id })
470
- return result if result.failure?
471
-
472
- issue_data = result.data['issue']
473
- if issue_data.nil?
474
- Result.new(success: false, error: "Issue not found: #{id}")
475
- else
476
- Result.new(success: true, data: Issue.new(issue_data), raw_response: result.raw_response)
477
- end
478
- end
479
-
480
466
  private
481
467
 
482
468
  def connection
483
469
  @connection ||= Faraday.new(url: API_ENDPOINT) do |f|
470
+ f.options.timeout = @read_timeout
471
+ f.options.open_timeout = @open_timeout
472
+ f.request :retry, max: @max_retries,
473
+ interval: DEFAULT_RETRY_INTERVAL,
474
+ backoff_factor: 2,
475
+ exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed],
476
+ retry_statuses: [500, 502, 503],
477
+ retry_block: lambda { |env, _opts, retries, exc|
478
+ LinearApi.logger.warn do
479
+ "LinearApi: retry #{retries}/#{@max_retries} after #{exc&.class || "HTTP #{env&.status}"}"
480
+ end
481
+ }
484
482
  f.headers['Content-Type'] = 'application/json'
485
483
  f.headers['Authorization'] = api_key
486
484
  f.adapter Faraday.default_adapter
@@ -488,15 +486,24 @@ module LinearApi
488
486
  end
489
487
 
490
488
  def handle_response(response)
489
+ # Check for rate limiting
490
+ if response.status == 429
491
+ retry_after = response.headers['retry-after']&.to_i
492
+ LinearApi.logger.warn { "LinearApi: rate limited, retry after #{retry_after || 'unknown'}s" }
493
+ raise RateLimitError.new("Rate limited by Linear API. Retry after #{retry_after || 60}s", retry_after: retry_after)
494
+ end
495
+
491
496
  data = JSON.parse(response.body)
492
497
 
493
498
  if data['errors']
494
499
  error_message = data['errors'].map { |e| e['message'] }.join(', ')
500
+ LinearApi.logger.error { "LinearApi: GraphQL error: #{error_message}" }
495
501
  return Result.new(success: false, error: error_message, raw_response: data)
496
502
  end
497
503
 
498
504
  Result.new(success: true, data: data['data'], raw_response: data)
499
505
  rescue JSON::ParserError => e
506
+ LinearApi.logger.error { "LinearApi: invalid JSON response: #{e.message}" }
500
507
  Result.new(success: false, error: "Invalid JSON: #{e.message}")
501
508
  end
502
509
 
@@ -19,6 +19,7 @@ module LinearApi
19
19
  # Auto-configure from environment variables if not already set
20
20
  LinearApi.api_key ||= ENV['LINEAR_API_KEY']
21
21
  LinearApi.team_id ||= ENV['LINEAR_TEAM_ID']
22
+ LinearApi.workspace_slug ||= ENV['LINEAR_WORKSPACE_SLUG']
22
23
  end
23
24
  end
24
25
  end