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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f5dbd45da7e5e461fd1f9c04b5b94d859b74a85f8be4b2818f2cb418597ed83
4
- data.tar.gz: d082eb5a92d5343d8eb5be7e41ebefa1c17aa20ecb347139dbe98c1a9fb533d9
3
+ metadata.gz: bfd85fc80770e0982a5791d46f49fb4406b356b9e9fa43be79a9af61d21e791b
4
+ data.tar.gz: 30b5161cf27bdb0a43d8f15a22f32693db5b75c018a07aa5860329b2898a01b7
5
5
  SHA512:
6
- metadata.gz: 01feecb156624a79c91954ba5527948e962ff6d64e9a8aaa2906e4afb633bf62e82527c8a6a9c75444782ec383b0918828c77ffb80e9ac68b08abe68c168859e
7
- data.tar.gz: 861150c54a6f3af9964af691dce4a43bb7cfcc48a6f8963ba82f5f7e76762f0b7ee5d94193f6f76283978713e4968d0aae56ae8c93cbc87845bb0a329a7f1056
6
+ metadata.gz: f6cd9e24591ae9ce4a807f7fb5758d8b759aa9a8f0b3e0c47fcdc600c55aba984781119c6319c58573370a69837761fe1bac307f052e019147007968b90080a8
7
+ data.tar.gz: be806327bb48559330ace30e6981146cbb7fcc27ad060027644951da7caa846dd9ee284c413625e684c74522a0bbaed6c3bba60594094f1144230903bcf0ae27
data/CHANGELOG.md CHANGED
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-02-07
9
+
10
+ ### Added
11
+
12
+ - **Connection timeouts**: Faraday now has 5s open timeout and 15s read timeout (prevents hung Puma threads)
13
+ - **Automatic retry**: `faraday-retry` middleware retries on 500/502/503 and network errors (3 retries with exponential backoff)
14
+ - **Rate limit handling**: HTTP 429 responses now raise `RateLimitError` with `retry_after` attribute
15
+ - **Thread-safe client**: `LinearApi.client` and `reset_client!` are now protected by a `Mutex`
16
+ - **Configurable logger**: `LinearApi.logger` with automatic Rails.logger detection; all API calls, errors, and retries are logged
17
+ - **Pagination support**: `list_all_issues` method with cursor-based pagination and optional block yielding
18
+ - **`search_issues` method**: Explicit full-text search (separated from `get_issue` which now uses direct filter)
19
+ - **`batch_get_issues` method**: Fetch multiple issues by ID in a single API call (avoids N+1)
20
+ - **`fetch_all_metadata` method**: Single GraphQL query to fetch labels, projects, states, and users at once
21
+ - **`due_date` and `estimate` fields**: Added to `Issue` model and all queries
22
+ - **`value!` method on `Result`**: Returns data on success, raises `LinearApi::Error` on failure
23
+ - **Health check**: `LinearApi.healthy?` method and `linear:health` rake task
24
+ - **Configurable workspace slug**: `LinearApi.workspace_slug` / `LINEAR_WORKSPACE_SLUG` env var for `SyncedIssue#linear_url`
25
+ - **Sensitive key redaction**: `IssueTracker` now redacts keys matching password/secret/token/api_key patterns from issue descriptions
26
+ - **CacheSync spec**: Full test coverage for `CacheSync` class
27
+ - **Network failure tests**: Tests for HTTP 429, timeouts, malformed JSON responses
28
+
29
+ ### Changed
30
+
31
+ - **BREAKING**: `Result` no longer delegates methods via `method_missing`. Use `result.data.method` or `result.value!.method` instead
32
+ - **BREAKING**: `get_issue('TOS-123')` now uses a direct filter query (`issues(filter: { identifier: { eq: ... } })`) instead of full-text search. This prevents false positives (e.g., "TOS-1" matching "TOS-10"). Use `search_issues(term:)` for fuzzy search.
33
+ - `CacheSync.sync_all` now fetches all metadata in a **single batched API call** (was 4 separate calls)
34
+ - `SyncedIssue.refresh_stale_issues!` now uses **batch fetch** (was N+1 individual API calls)
35
+ - `IssueTracker` now memoizes auto-tracked project/label IDs in memory after first resolution
36
+ - `IssueTracker.track_error` and `track_issue` now rescue all errors and return failure Result (never lets tracking failures propagate)
37
+ - All inline GraphQL queries moved from `Client` to their respective model classes as constants
38
+ - Rake task `export_cache` now fetches team key from API instead of hardcoding `'TOS'`
39
+ - Rake task helper methods moved to `self.` class methods to avoid polluting global scope
40
+ - `faraday-retry` added as a runtime dependency
41
+
42
+ ### Fixed
43
+
44
+ - `SyncedIssue#linear_url` no longer hardcodes `theownerstack` workspace slug
45
+ - `RateLimitError` now includes `retry_after` attribute (was defined but never raised)
46
+
47
+ ## [0.4.0] - 2026-02-06
48
+
49
+ ### Changed
50
+
51
+ - **BREAKING (dependency)**: Rails moved from runtime to development dependency. The core client (`Client`, `Result`, `Issue`, `Project`, `Label`, `Team`) now only requires `faraday`. Rails Engine, `CacheSync`, and `IssueTracker` are still loaded automatically when Rails is present via `defined?(Rails::Engine)` guard.
52
+ - This allows non-Rails projects (e.g., standalone gems, CLI tools) to use `linear_api` without pulling in all of Rails.
53
+
8
54
  ## [0.3.1] - 2026-02-06
9
55
 
10
56
  ### Added
data/README.md CHANGED
@@ -7,9 +7,7 @@ A Ruby gem for interacting with the [Linear](https://linear.app) GraphQL API. Cr
7
7
  Add this line to your application's Gemfile:
8
8
 
9
9
  ```ruby
10
- gem 'linear_api', path: '../linear'
11
- # Or from GitHub:
12
- gem 'linear_api', git: 'https://github.com/dan1d/linear_api.git'
10
+ gem 'linear_api'
13
11
  ```
14
12
 
15
13
  Then run:
@@ -18,6 +16,12 @@ Then run:
18
16
  bundle install
19
17
  ```
20
18
 
19
+ Or install it directly:
20
+
21
+ ```bash
22
+ gem install linear_api
23
+ ```
24
+
21
25
  For Rails applications, run the migrations:
22
26
 
23
27
  ```bash
@@ -33,6 +37,8 @@ require 'linear_api'
33
37
  LinearApi.configure do |config|
34
38
  config.api_key = ENV['LINEAR_API_KEY']
35
39
  config.team_id = 'your-team-id' # e.g., 'cfe09c0a-dcae-4cf9-be66-621d00798fcf'
40
+ config.workspace_slug = 'your-workspace' # Used for building issue URLs (default: 'theownerstack')
41
+ config.logger = Rails.logger # Optional: defaults to Rails.logger or stdout
36
42
  end
37
43
  ```
38
44
 
@@ -45,7 +51,9 @@ result = LinearApi.client.create_issue(
45
51
  title: 'Bug: Login fails with special characters',
46
52
  description: '## Problem\n\nUsers cannot login when password contains @',
47
53
  priority: 2, # 1=Urgent, 2=High, 3=Medium, 4=Low
48
- label_ids: ['752f6bfb-9943-4dde-8d36-63b43c34c12f'] # type:bug
54
+ label_ids: ['752f6bfb-9943-4dde-8d36-63b43c34c12f'],
55
+ due_date: '2026-03-01', # ISO 8601 date
56
+ estimate: 3 # Story points
49
57
  )
50
58
 
51
59
  if result.success?
@@ -54,11 +62,16 @@ if result.success?
54
62
  else
55
63
  puts "Error: #{result.error}"
56
64
  end
65
+
66
+ # Or use value! to raise on failure:
67
+ issue = LinearApi.client.create_issue(title: 'Bug').value!
68
+ puts issue.identifier
57
69
  ```
58
70
 
59
71
  ### Get an Issue
60
72
 
61
73
  ```ruby
74
+ # Direct lookup by identifier (exact match)
62
75
  result = LinearApi.client.get_issue('TOS-123')
63
76
 
64
77
  if result.success?
@@ -66,6 +79,18 @@ if result.success?
66
79
  puts "Title: #{issue.title}"
67
80
  puts "State: #{issue.state_name}"
68
81
  puts "Labels: #{issue.label_names.join(', ')}"
82
+ puts "Due: #{issue.due_date}"
83
+ puts "Estimate: #{issue.estimate}"
84
+ end
85
+ ```
86
+
87
+ ### Search Issues (Full-Text)
88
+
89
+ ```ruby
90
+ result = LinearApi.client.search_issues(term: 'login bug', limit: 10)
91
+
92
+ result.data.each do |issue|
93
+ puts "#{issue.identifier}: #{issue.title}"
69
94
  end
70
95
  ```
71
96
 
@@ -97,11 +122,31 @@ result = LinearApi.client.add_comment(
97
122
  ### List Issues
98
123
 
99
124
  ```ruby
125
+ # Simple list (up to 50)
100
126
  result = LinearApi.client.list_issues(limit: 50)
101
127
 
102
128
  result.data.each do |issue|
103
129
  puts "#{issue.identifier}: #{issue.title} [#{issue.state_name}]"
104
130
  end
131
+
132
+ # List ALL issues with automatic pagination
133
+ result = LinearApi.client.list_all_issues(batch_size: 50) do |issue|
134
+ # Optional: process each issue as it's fetched
135
+ puts "Fetched: #{issue.identifier}"
136
+ end
137
+
138
+ puts "Total: #{result.data.length}"
139
+ ```
140
+
141
+ ### Batch Fetch Issues
142
+
143
+ ```ruby
144
+ # Fetch multiple issues in a single API call (avoids N+1)
145
+ result = LinearApi.client.batch_get_issues(ids: ['uuid-1', 'uuid-2', 'uuid-3'])
146
+
147
+ result.data.each do |issue|
148
+ puts "#{issue.identifier}: #{issue.state_name}"
149
+ end
105
150
  ```
106
151
 
107
152
  ### List Labels
@@ -168,6 +213,20 @@ end
168
213
  LinearApi.client.assign(id: issue.id, assignee_id: 'user-uuid')
169
214
  ```
170
215
 
216
+ ### Fetch All Metadata (Single API Call)
217
+
218
+ ```ruby
219
+ # Fetch labels, projects, states, and users in one batched call
220
+ result = LinearApi.client.fetch_all_metadata
221
+
222
+ if result.success?
223
+ puts "Labels: #{result.data[:labels].length}"
224
+ puts "Projects: #{result.data[:projects].length}"
225
+ puts "States: #{result.data[:states].length}"
226
+ puts "Users: #{result.data[:users].length}"
227
+ end
228
+ ```
229
+
171
230
  ### Archive/Unarchive Issues
172
231
 
173
232
  ```ruby
@@ -230,14 +289,31 @@ puts result.data['viewer']['name']
230
289
  ```ruby
231
290
  result = LinearApi.client.create_issue(title: 'Test')
232
291
 
233
- case result
234
- when ->(r) { r.success? }
235
- # Handle success
292
+ if result.success?
236
293
  puts result.data.identifier
237
- when ->(r) { r.failure? }
238
- # Handle error
294
+ else
239
295
  puts "Error: #{result.error}"
240
296
  end
297
+
298
+ # Or use value! to raise on failure (good for scripts)
299
+ begin
300
+ issue = LinearApi.client.create_issue(title: 'Test').value!
301
+ puts issue.identifier
302
+ rescue LinearApi::Error => e
303
+ puts "Failed: #{e.message}"
304
+ rescue LinearApi::RateLimitError => e
305
+ puts "Rate limited, retry after #{e.retry_after}s"
306
+ end
307
+ ```
308
+
309
+ ## Health Check
310
+
311
+ ```ruby
312
+ if LinearApi.healthy?
313
+ puts "Linear API is reachable"
314
+ else
315
+ puts "Cannot connect to Linear"
316
+ end
241
317
  ```
242
318
 
243
319
  ## Rails Engine (Auto-Tracking)
@@ -247,10 +323,13 @@ The gem includes a Rails Engine for automatic error tracking with deduplication.
247
323
  ### Setup
248
324
 
249
325
  ```bash
326
+ # Verify Linear API connectivity
327
+ bin/rails linear:health
328
+
250
329
  # Create the auto-tracking project and label in Linear
251
330
  bin/rails linear:setup
252
331
 
253
- # Sync labels, projects, and states from Linear
332
+ # Sync labels, projects, and states from Linear (single batched API call)
254
333
  bin/rails linear:sync_cache
255
334
  ```
256
335
 
@@ -262,7 +341,7 @@ class DailySalesReportJob < ApplicationJob
262
341
  def perform(account)
263
342
  # ... generate report ...
264
343
  rescue StandardError => e
265
- # Track the error in Linear
344
+ # Track the error in Linear (never raises, returns Result)
266
345
  LinearApi::IssueTracker.track_error(
267
346
  exception: e,
268
347
  context: { date: Date.current, account_id: account.id },
@@ -287,6 +366,21 @@ LinearApi::IssueTracker.track_issue(
287
366
  )
288
367
  ```
289
368
 
369
+ ### Security: Sensitive Data Redaction
370
+
371
+ `IssueTracker` automatically redacts context values whose keys match sensitive patterns (password, secret, token, api_key, credential, authorization):
372
+
373
+ ```ruby
374
+ LinearApi::IssueTracker.track_error(
375
+ exception: e,
376
+ context: {
377
+ account_id: 123, # Included as-is
378
+ api_key: 'sk_live_xxx', # Redacted to '[REDACTED]'
379
+ user_email: 'user@test.com' # Included as-is
380
+ }
381
+ )
382
+ ```
383
+
290
384
  ### How Deduplication Works
291
385
 
292
386
  1. Each error generates a fingerprint based on exception class, message, and backtrace
@@ -296,13 +390,16 @@ LinearApi::IssueTracker.track_issue(
296
390
  ### Rake Tasks
297
391
 
298
392
  ```bash
393
+ # Verify API connectivity
394
+ bin/rails linear:health
395
+
299
396
  # Setup auto-tracking project and label
300
397
  bin/rails linear:setup
301
398
 
302
- # Sync metadata from Linear API
399
+ # Sync metadata from Linear API (single batched call)
303
400
  bin/rails linear:sync_cache
304
401
 
305
- # Refresh local issue states (check if resolved)
402
+ # Refresh local issue states (batched, checks if resolved)
306
403
  bin/rails linear:refresh_issues
307
404
 
308
405
  # Show cache statistics
@@ -344,9 +441,18 @@ account_issues = LinearApi::SyncedIssue.where(
344
441
  # Check if an issue is still open
345
442
  issue = LinearApi::SyncedIssue.find_by(identifier: 'TOS-123')
346
443
  puts issue.open? # => true/false
347
- puts issue.linear_url # => "https://linear.app/theownerstack/issue/TOS-123"
444
+ puts issue.linear_url # => "https://linear.app/your-workspace/issue/TOS-123"
348
445
  ```
349
446
 
447
+ ## Resilience Features
448
+
449
+ - **Connection timeouts**: 5s connect, 15s read (configurable per-client)
450
+ - **Automatic retries**: 3 retries with exponential backoff on 500/502/503 and network errors
451
+ - **Rate limit detection**: HTTP 429 raises `RateLimitError` with `retry_after`
452
+ - **Thread safety**: Module-level client access is protected by `Mutex`
453
+ - **Logging**: All API calls, retries, and errors logged via configurable `LinearApi.logger`
454
+ - **Silent error tracking**: `IssueTracker.track_error` never raises -- returns failure `Result` and logs
455
+
350
456
  ## Development
351
457
 
352
458
  ```bash
@@ -42,9 +42,10 @@ module LinearApi
42
42
  true
43
43
  end
44
44
 
45
- # Build Linear URL
45
+ # Build Linear URL using configurable workspace slug
46
46
  def linear_url
47
- "https://linear.app/theownerstack/issue/#{identifier}"
47
+ slug = LinearApi.workspace_slug || 'theownerstack'
48
+ "https://linear.app/#{slug}/issue/#{identifier}"
48
49
  end
49
50
 
50
51
  # Parse metadata JSON
@@ -67,9 +68,42 @@ module LinearApi
67
68
  find_open_for_fingerprint(fingerprint).nil?
68
69
  end
69
70
 
70
- # Refresh all stale issues
71
+ # Refresh all stale issues using batch API call (avoids N+1)
71
72
  def refresh_stale_issues!
72
- stale.find_each(&:refresh_from_linear!)
73
+ stale_issues = stale.to_a
74
+ return if stale_issues.empty?
75
+
76
+ LinearApi.logger.info { "LinearApi: refreshing #{stale_issues.size} stale issues" }
77
+
78
+ # Batch fetch from Linear API in groups of 50
79
+ stale_issues.each_slice(50) do |batch|
80
+ ids = batch.map(&:linear_id)
81
+ result = LinearApi.client.batch_get_issues(ids: ids)
82
+
83
+ unless result.success?
84
+ LinearApi.logger.error { "LinearApi: batch refresh failed: #{result.error}" }
85
+ # Fall back to individual refresh for this batch
86
+ batch.each(&:refresh_from_linear!)
87
+ next
88
+ end
89
+
90
+ # Index fetched issues by ID for O(1) lookup
91
+ fetched = result.data.index_by(&:id)
92
+
93
+ batch.each do |synced|
94
+ linear_issue = fetched[synced.linear_id]
95
+ if linear_issue
96
+ synced.update!(
97
+ state_name: linear_issue.state_name,
98
+ title: linear_issue.title,
99
+ priority: linear_issue.priority,
100
+ synced_at: Time.current
101
+ )
102
+ else
103
+ LinearApi.logger.warn { "LinearApi: issue #{synced.identifier} not found in Linear (may be deleted)" }
104
+ end
105
+ end
106
+ end
73
107
  end
74
108
  end
75
109
  end
@@ -12,98 +12,57 @@ module LinearApi
12
12
  end
13
13
  end
14
14
 
15
+ # Sync all metadata in a single API call (instead of 4 separate calls)
15
16
  def sync_all
17
+ LinearApi.logger.info { 'LinearApi: syncing all metadata from Linear API' }
18
+
19
+ result = LinearApi.client.fetch_all_metadata
20
+ unless result.success?
21
+ LinearApi.logger.error { "LinearApi: metadata sync failed: #{result.error}" }
22
+ return {
23
+ labels: { success: false, error: result.error },
24
+ projects: { success: false, error: result.error },
25
+ states: { success: false, error: result.error },
26
+ users: { success: false, error: result.error }
27
+ }
28
+ end
29
+
30
+ metadata = result.data
16
31
  {
17
- labels: sync_labels,
18
- projects: sync_projects,
19
- states: sync_states,
20
- users: sync_users
32
+ labels: sync_labels_from_data(metadata[:labels]),
33
+ projects: sync_projects_from_data(metadata[:projects]),
34
+ states: sync_states_from_data(metadata[:states]),
35
+ users: sync_users_from_data(metadata[:users])
21
36
  }
22
37
  end
23
38
 
39
+ # Individual sync methods (for targeted refresh)
24
40
  def sync_labels
25
41
  result = LinearApi.client.list_labels
26
42
  return { success: false, error: result.error } unless result.success?
27
43
 
28
- count = 0
29
- result.data.each do |label|
30
- category = extract_category(label.name)
31
- CachedMetadata.upsert_from_api(
32
- type: 'label',
33
- linear_id: label.id,
34
- name: label.name,
35
- key: label.name,
36
- color: label.color,
37
- category: category,
38
- raw_data: label.to_h
39
- )
40
- count += 1
41
- end
42
-
43
- { success: true, count: count }
44
+ sync_labels_from_data(result.data)
44
45
  end
45
46
 
46
47
  def sync_projects
47
48
  result = LinearApi.client.list_projects
48
49
  return { success: false, error: result.error } unless result.success?
49
50
 
50
- count = 0
51
- result.data.each do |project|
52
- key = project.name.parameterize.underscore
53
- CachedMetadata.upsert_from_api(
54
- type: 'project',
55
- linear_id: project.id,
56
- name: project.name,
57
- key: key,
58
- state_type: project.state,
59
- raw_data: project.to_h
60
- )
61
- count += 1
62
- end
63
-
64
- { success: true, count: count }
51
+ sync_projects_from_data(result.data)
65
52
  end
66
53
 
67
54
  def sync_states
68
55
  result = LinearApi.client.list_states
69
56
  return { success: false, error: result.error } unless result.success?
70
57
 
71
- count = 0
72
- result.data.each do |state|
73
- key = state['name'].parameterize.underscore
74
- CachedMetadata.upsert_from_api(
75
- type: 'state',
76
- linear_id: state['id'],
77
- name: state['name'],
78
- key: key,
79
- color: state['color'],
80
- state_type: state['type'],
81
- raw_data: state
82
- )
83
- count += 1
84
- end
85
-
86
- { success: true, count: count }
58
+ sync_states_from_data(result.data)
87
59
  end
88
60
 
89
61
  def sync_users
90
62
  result = LinearApi.client.list_users
91
63
  return { success: false, error: result.error } unless result.success?
92
64
 
93
- count = 0
94
- result.data.each do |user|
95
- key = user['email']&.split('@')&.first || user['name'].parameterize.underscore
96
- CachedMetadata.upsert_from_api(
97
- type: 'user',
98
- linear_id: user['id'],
99
- name: user['name'],
100
- key: key,
101
- raw_data: user
102
- )
103
- count += 1
104
- end
105
-
106
- { success: true, count: count }
65
+ sync_users_from_data(result.data)
107
66
  end
108
67
 
109
68
  # Import from linear.json file
@@ -157,6 +116,83 @@ module LinearApi
157
116
 
158
117
  private
159
118
 
119
+ def sync_labels_from_data(labels)
120
+ count = 0
121
+ labels.each do |label|
122
+ category = extract_category(label.is_a?(Label) ? label.name : label['name'])
123
+ CachedMetadata.upsert_from_api(
124
+ type: 'label',
125
+ linear_id: label.is_a?(Label) ? label.id : label['id'],
126
+ name: label.is_a?(Label) ? label.name : label['name'],
127
+ key: label.is_a?(Label) ? label.name : label['name'],
128
+ color: label.is_a?(Label) ? label.color : label['color'],
129
+ category: category,
130
+ raw_data: label.is_a?(Label) ? label.to_h : label
131
+ )
132
+ count += 1
133
+ end
134
+
135
+ { success: true, count: count }
136
+ end
137
+
138
+ def sync_projects_from_data(projects)
139
+ count = 0
140
+ projects.each do |project|
141
+ name = project.is_a?(Project) ? project.name : project['name']
142
+ key = name.parameterize.underscore
143
+ CachedMetadata.upsert_from_api(
144
+ type: 'project',
145
+ linear_id: project.is_a?(Project) ? project.id : project['id'],
146
+ name: name,
147
+ key: key,
148
+ state_type: project.is_a?(Project) ? project.state : project['state'],
149
+ raw_data: project.is_a?(Project) ? project.to_h : project
150
+ )
151
+ count += 1
152
+ end
153
+
154
+ { success: true, count: count }
155
+ end
156
+
157
+ def sync_states_from_data(states)
158
+ count = 0
159
+ states.each do |state|
160
+ name = state.is_a?(Hash) ? state['name'] : state.name
161
+ key = name.parameterize.underscore
162
+ CachedMetadata.upsert_from_api(
163
+ type: 'state',
164
+ linear_id: state.is_a?(Hash) ? state['id'] : state.id,
165
+ name: name,
166
+ key: key,
167
+ color: state.is_a?(Hash) ? state['color'] : state.color,
168
+ state_type: state.is_a?(Hash) ? state['type'] : state.type,
169
+ raw_data: state.is_a?(Hash) ? state : state.to_h
170
+ )
171
+ count += 1
172
+ end
173
+
174
+ { success: true, count: count }
175
+ end
176
+
177
+ def sync_users_from_data(users)
178
+ count = 0
179
+ users.each do |user|
180
+ name = user.is_a?(Hash) ? user['name'] : user.name
181
+ email = user.is_a?(Hash) ? user['email'] : user.email
182
+ key = email&.split('@')&.first || name.parameterize.underscore
183
+ CachedMetadata.upsert_from_api(
184
+ type: 'user',
185
+ linear_id: user.is_a?(Hash) ? user['id'] : user.id,
186
+ name: name,
187
+ key: key,
188
+ raw_data: user.is_a?(Hash) ? user : user.to_h
189
+ )
190
+ count += 1
191
+ end
192
+
193
+ { success: true, count: count }
194
+ end
195
+
160
196
  def extract_category(label_name)
161
197
  return nil unless label_name.include?(':')
162
198