linear_api 0.3.2 → 0.5.1

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,29 @@ 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
- # @param identifier [String] Issue identifier
98
+ # Parses the identifier into team key and issue number, then filters
99
+ # using the `number` and `team.key` fields on IssueFilter (Linear's
100
+ # GraphQL schema does not expose an `identifier` filter).
101
+ #
102
+ # @param identifier [String] Issue identifier (e.g., "TOS-123")
75
103
  # @return [Result] Result with issue
76
104
  def get_issue(identifier)
77
- result = query(Issue::SEARCH_QUERY, variables: { term: identifier })
105
+ team_key, number = parse_identifier(identifier)
106
+ unless team_key && number
107
+ return Result.new(success: false, error: "Invalid identifier format: #{identifier} (expected TEAM-123)")
108
+ end
109
+
110
+ filter = {
111
+ number: { eq: number },
112
+ team: { key: { eq: team_key } }
113
+ }
114
+
115
+ result = query(Issue::GET_BY_IDENTIFIER_QUERY, variables: { filter: filter })
78
116
  return result if result.failure?
79
117
 
80
- issues = result.data.dig('searchIssues', 'nodes') || []
118
+ issues = result.data.dig('issues', 'nodes') || []
81
119
  if issues.empty?
82
120
  Result.new(success: false, error: "Issue not found: #{identifier}")
83
121
  else
@@ -85,6 +123,19 @@ module LinearApi
85
123
  end
86
124
  end
87
125
 
126
+ # Search issues by text query (full-text search)
127
+ #
128
+ # @param term [String] Search term
129
+ # @param limit [Integer] Max results
130
+ # @return [Result] Result with array of issues
131
+ def search_issues(term:, limit: 10)
132
+ result = query(Issue::SEARCH_QUERY, variables: { term: term, first: limit })
133
+ return result if result.failure?
134
+
135
+ issues = (result.data.dig('searchIssues', 'nodes') || []).map { |i| Issue.new(i) }
136
+ Result.new(success: true, data: issues, raw_response: result.raw_response)
137
+ end
138
+
88
139
  # List issues for the team
89
140
  #
90
141
  # @param filter [Hash] Filter options
@@ -101,6 +152,39 @@ module LinearApi
101
152
  Result.new(success: true, data: issues, raw_response: result.raw_response)
102
153
  end
103
154
 
155
+ # List all issues with automatic pagination
156
+ #
157
+ # @param filter [Hash] Filter options
158
+ # @param batch_size [Integer] Items per page
159
+ # @yield [Issue] Each issue as it's fetched (optional)
160
+ # @return [Result] Result with array of all issues
161
+ def list_all_issues(filter: nil, batch_size: 50, &block)
162
+ all_issues = []
163
+ cursor = nil
164
+
165
+ loop do
166
+ variables = { teamId: team_id, first: batch_size }
167
+ variables[:filter] = filter if filter
168
+ variables[:after] = cursor if cursor
169
+
170
+ result = query(Issue::LIST_PAGINATED_QUERY, variables: variables)
171
+ return result if result.failure?
172
+
173
+ nodes = (result.data.dig('team', 'issues', 'nodes') || []).map { |i| Issue.new(i) }
174
+ nodes.each do |issue|
175
+ block&.call(issue)
176
+ all_issues << issue
177
+ end
178
+
179
+ page_info = result.data.dig('team', 'issues', 'pageInfo')
180
+ break unless page_info&.dig('hasNextPage')
181
+
182
+ cursor = page_info['endCursor']
183
+ end
184
+
185
+ Result.new(success: true, data: all_issues)
186
+ end
187
+
104
188
  # Add a comment to an issue
105
189
  #
106
190
  # @param issue_id [String] Issue ID
@@ -117,89 +201,88 @@ module LinearApi
117
201
  )
118
202
  end
119
203
 
120
- # Get team info
204
+ # Get issue by UUID (not identifier)
121
205
  #
122
- # @return [Result] Result with team data
123
- def get_team
124
- result = query(Team::GET_QUERY, variables: { teamId: team_id })
206
+ # @param id [String] Issue UUID
207
+ # @return [Result] Result with issue
208
+ def get_issue_by_id(id)
209
+ result = query(Issue::GET_BY_ID_QUERY, variables: { id: id })
125
210
  return result if result.failure?
126
211
 
127
- team_data = result.data['team']
128
- Result.new(success: true, data: Team.new(team_data), raw_response: result.raw_response)
212
+ issue_data = result.data['issue']
213
+ if issue_data.nil?
214
+ Result.new(success: false, error: "Issue not found: #{id}")
215
+ else
216
+ Result.new(success: true, data: Issue.new(issue_data), raw_response: result.raw_response)
217
+ end
129
218
  end
130
219
 
131
- # List projects for the team
220
+ # Batch fetch issues by IDs (avoids N+1 API calls)
132
221
  #
133
- # @return [Result] Result with array of projects
134
- def list_projects
135
- result = query(Project::LIST_QUERY, variables: { teamId: team_id })
222
+ # @param ids [Array<String>] Issue UUIDs
223
+ # @return [Result] Result with array of issues
224
+ def batch_get_issues(ids:)
225
+ result = query(Issue::BATCH_GET_QUERY, variables: { filter: { id: { in: ids } } })
136
226
  return result if result.failure?
137
227
 
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)
228
+ issues = (result.data.dig('issues', 'nodes') || []).map { |i| Issue.new(i) }
229
+ Result.new(success: true, data: issues, raw_response: result.raw_response)
140
230
  end
141
231
 
142
- # Create a new project
232
+ # Archive an issue (soft delete)
143
233
  #
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 })
234
+ # @param id [String] Issue ID
235
+ # @return [Result] Result with success status
236
+ def archive_issue(id:)
237
+ result = query(Issue::ARCHIVE_MUTATION, variables: { id: id })
169
238
  return result if result.failure?
170
239
 
171
- project_data = result.data.dig('projectCreate', 'project')
172
240
  Result.new(
173
- success: result.data.dig('projectCreate', 'success'),
174
- data: Project.new(project_data),
241
+ success: result.data.dig('issueArchive', 'success'),
242
+ data: { archived: true },
175
243
  raw_response: result.raw_response
176
244
  )
177
245
  end
178
246
 
179
- # List labels for the team
247
+ # Unarchive an issue
180
248
  #
181
- # @return [Result] Result with array of labels
182
- def list_labels
183
- result = query(Label::LIST_QUERY, variables: { teamId: team_id })
249
+ # @param id [String] Issue ID
250
+ # @return [Result] Result with success status
251
+ def unarchive_issue(id:)
252
+ result = query(Issue::UNARCHIVE_MUTATION, variables: { id: id })
184
253
  return result if result.failure?
185
254
 
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)
255
+ Result.new(
256
+ success: result.data.dig('issueUnarchive', 'success'),
257
+ data: { archived: false },
258
+ raw_response: result.raw_response
259
+ )
188
260
  end
189
261
 
190
- # Get workflow states for the team
262
+ # Create a sub-issue (child issue)
191
263
  #
192
- # @return [Result] Result with array of states
193
- def list_states
194
- result = query(Team::STATES_QUERY, variables: { teamId: team_id })
264
+ # @param parent_id [String] Parent issue ID
265
+ # @param title [String] Sub-issue title
266
+ # @param options [Hash] Additional options
267
+ # @return [Result] Result with created sub-issue
268
+ def create_sub_issue(parent_id:, title:, **options)
269
+ create_issue(title: title, parent_id: parent_id, **options)
270
+ end
271
+
272
+ # Get sub-issues for an issue
273
+ #
274
+ # @param parent_id [String] Parent issue ID
275
+ # @return [Result] Result with array of sub-issues
276
+ def list_sub_issues(parent_id:)
277
+ result = query(Issue::SUB_ISSUES_QUERY, variables: { id: parent_id })
195
278
  return result if result.failure?
196
279
 
197
- states = result.data.dig('team', 'states', 'nodes') || []
198
- Result.new(success: true, data: states, raw_response: result.raw_response)
280
+ children = (result.data.dig('issue', 'children', 'nodes') || []).map { |i| Issue.new(i) }
281
+ Result.new(success: true, data: children, raw_response: result.raw_response)
199
282
  end
200
283
 
201
284
  # ==========================================================================
202
- # Convenience Methods for Common Operations
285
+ # Convenience Methods for Common Issue Operations
203
286
  # ==========================================================================
204
287
 
205
288
  # Move an issue to a different state
@@ -217,7 +300,6 @@ module LinearApi
217
300
  # @param label_ids [Array<String>] Label IDs to add
218
301
  # @return [Result] Result with updated issue
219
302
  def add_labels(id:, label_ids:)
220
- # First get current labels
221
303
  issue_result = get_issue_by_id(id)
222
304
  return issue_result if issue_result.failure?
223
305
 
@@ -257,19 +339,7 @@ module LinearApi
257
339
  # @param pr_url [String] GitHub PR URL
258
340
  # @return [Result] Result with attachment
259
341
  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 })
342
+ result = query(Issue::LINK_PR_MUTATION, variables: { issueId: issue_id, url: pr_url })
273
343
  return result if result.failure?
274
344
 
275
345
  Result.new(
@@ -279,79 +349,112 @@ module LinearApi
279
349
  )
280
350
  end
281
351
 
352
+ # ==========================================================================
353
+ # Team Operations
354
+ # ==========================================================================
355
+
356
+ # Get team info
357
+ #
358
+ # @return [Result] Result with team data
359
+ def get_team
360
+ result = query(Team::GET_QUERY, variables: { teamId: team_id })
361
+ return result if result.failure?
362
+
363
+ team_data = result.data['team']
364
+ Result.new(success: true, data: Team.new(team_data), raw_response: result.raw_response)
365
+ end
366
+
367
+ # Get workflow states for the team
368
+ #
369
+ # @return [Result] Result with array of states
370
+ def list_states
371
+ result = query(Team::STATES_QUERY, variables: { teamId: team_id })
372
+ return result if result.failure?
373
+
374
+ states = result.data.dig('team', 'states', 'nodes') || []
375
+ Result.new(success: true, data: states, raw_response: result.raw_response)
376
+ end
377
+
282
378
  # List team members for assignment
283
379
  #
284
380
  # @return [Result] Result with array of users
285
381
  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 })
382
+ result = query(Team::USERS_QUERY, variables: { teamId: team_id })
303
383
  return result if result.failure?
304
384
 
305
385
  users = result.data.dig('team', 'members', 'nodes') || []
306
386
  Result.new(success: true, data: users, raw_response: result.raw_response)
307
387
  end
308
388
 
309
- # Archive an issue (soft delete)
389
+ # Fetch all team metadata in a single API call (labels, projects, states, users)
310
390
  #
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 })
391
+ # @return [Result] Result with hash of { labels:, projects:, states:, users: }
392
+ def fetch_all_metadata
393
+ result = query(Team::ALL_METADATA_QUERY, variables: { teamId: team_id })
323
394
  return result if result.failure?
324
395
 
325
- Result.new(
326
- success: result.data.dig('issueArchive', 'success'),
327
- data: { archived: true },
328
- raw_response: result.raw_response
329
- )
396
+ team = result.data['team']
397
+ data = {
398
+ labels: (team.dig('labels', 'nodes') || []).map { |l| Label.new(l) },
399
+ projects: (team.dig('projects', 'nodes') || []).map { |p| Project.new(p) },
400
+ states: team.dig('states', 'nodes') || [],
401
+ users: team.dig('members', 'nodes') || []
402
+ }
403
+ Result.new(success: true, data: data, raw_response: result.raw_response)
330
404
  end
331
405
 
332
- # Unarchive an issue
406
+ # ==========================================================================
407
+ # Project Operations
408
+ # ==========================================================================
409
+
410
+ # List projects for the team
333
411
  #
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 })
412
+ # @return [Result] Result with array of projects
413
+ def list_projects
414
+ result = query(Project::LIST_QUERY, variables: { teamId: team_id })
346
415
  return result if result.failure?
347
416
 
417
+ projects = (result.data.dig('team', 'projects', 'nodes') || []).map { |p| Project.new(p) }
418
+ Result.new(success: true, data: projects, raw_response: result.raw_response)
419
+ end
420
+
421
+ # Create a new project
422
+ #
423
+ # @param name [String] Project name
424
+ # @param description [String] Optional project description
425
+ # @param color [String] Optional hex color (e.g., "#0052CC")
426
+ # @return [Result] Result with created project
427
+ def create_project(name:, description: nil, color: nil)
428
+ input = { name: name, teamIds: [team_id] }
429
+ input[:description] = description if description
430
+ input[:color] = color if color
431
+
432
+ result = query(Project::CREATE_MUTATION, variables: { input: input })
433
+ return result if result.failure?
434
+
435
+ project_data = result.data.dig('projectCreate', 'project')
348
436
  Result.new(
349
- success: result.data.dig('issueUnarchive', 'success'),
350
- data: { archived: false },
437
+ success: result.data.dig('projectCreate', 'success'),
438
+ data: Project.new(project_data),
351
439
  raw_response: result.raw_response
352
440
  )
353
441
  end
354
442
 
443
+ # ==========================================================================
444
+ # Label Operations
445
+ # ==========================================================================
446
+
447
+ # List labels for the team
448
+ #
449
+ # @return [Result] Result with array of labels
450
+ def list_labels
451
+ result = query(Label::LIST_QUERY, variables: { teamId: team_id })
452
+ return result if result.failure?
453
+
454
+ labels = (result.data.dig('team', 'labels', 'nodes') || []).map { |l| Label.new(l) }
455
+ Result.new(success: true, data: labels, raw_response: result.raw_response)
456
+ end
457
+
355
458
  # Create a new label
356
459
  #
357
460
  # @param name [String] Label name (e.g., "type:bug", "area:sync")
@@ -359,25 +462,11 @@ module LinearApi
359
462
  # @param description [String] Optional description
360
463
  # @return [Result] Result with created label
361
464
  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
465
  input = { name: name, teamId: team_id }
377
466
  input[:color] = color if color
378
467
  input[:description] = description if description
379
468
 
380
- result = query(create_label_mutation, variables: { input: input })
469
+ result = query(Label::CREATE_MUTATION, variables: { input: input })
381
470
  return result if result.failure?
382
471
 
383
472
  label_data = result.data.dig('issueLabelCreate', 'issueLabel')
@@ -388,99 +477,22 @@ module LinearApi
388
477
  )
389
478
  end
390
479
 
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
480
  private
481
481
 
482
482
  def connection
483
483
  @connection ||= Faraday.new(url: API_ENDPOINT) do |f|
484
+ f.options.timeout = @read_timeout
485
+ f.options.open_timeout = @open_timeout
486
+ f.request :retry, max: @max_retries,
487
+ interval: DEFAULT_RETRY_INTERVAL,
488
+ backoff_factor: 2,
489
+ exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed],
490
+ retry_statuses: [500, 502, 503],
491
+ retry_block: lambda { |env, _opts, retries, exc|
492
+ LinearApi.logger.warn do
493
+ "LinearApi: retry #{retries}/#{@max_retries} after #{exc&.class || "HTTP #{env&.status}"}"
494
+ end
495
+ }
484
496
  f.headers['Content-Type'] = 'application/json'
485
497
  f.headers['Authorization'] = api_key
486
498
  f.adapter Faraday.default_adapter
@@ -488,15 +500,24 @@ module LinearApi
488
500
  end
489
501
 
490
502
  def handle_response(response)
503
+ # Check for rate limiting
504
+ if response.status == 429
505
+ retry_after = response.headers['retry-after']&.to_i
506
+ LinearApi.logger.warn { "LinearApi: rate limited, retry after #{retry_after || 'unknown'}s" }
507
+ raise RateLimitError.new("Rate limited by Linear API. Retry after #{retry_after || 60}s", retry_after: retry_after)
508
+ end
509
+
491
510
  data = JSON.parse(response.body)
492
511
 
493
512
  if data['errors']
494
513
  error_message = data['errors'].map { |e| e['message'] }.join(', ')
514
+ LinearApi.logger.error { "LinearApi: GraphQL error: #{error_message}" }
495
515
  return Result.new(success: false, error: error_message, raw_response: data)
496
516
  end
497
517
 
498
518
  Result.new(success: true, data: data['data'], raw_response: data)
499
519
  rescue JSON::ParserError => e
520
+ LinearApi.logger.error { "LinearApi: invalid JSON response: #{e.message}" }
500
521
  Result.new(success: false, error: "Invalid JSON: #{e.message}")
501
522
  end
502
523
 
@@ -512,6 +533,17 @@ module LinearApi
512
533
  input
513
534
  end
514
535
 
536
+ # Parse an identifier like "TOS-123" into ["TOS", 123]
537
+ #
538
+ # @param identifier [String] e.g. "TOS-123"
539
+ # @return [Array(String, Integer), Array(nil, nil)] team key and number, or nils
540
+ def parse_identifier(identifier)
541
+ match = identifier&.match(/\A([A-Za-z]+)-(\d+)\z/)
542
+ return [nil, nil] unless match
543
+
544
+ [match[1].upcase, match[2].to_i]
545
+ end
546
+
515
547
  def camelize(key)
516
548
  key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }.to_sym
517
549
  end
@@ -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