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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8f5dbd45da7e5e461fd1f9c04b5b94d859b74a85f8be4b2818f2cb418597ed83
4
- data.tar.gz: d082eb5a92d5343d8eb5be7e41ebefa1c17aa20ecb347139dbe98c1a9fb533d9
3
+ metadata.gz: 6b6fd5b3c475c49a2fa516dd0c0efeb160021fff64c92baeded00a2915e14cc1
4
+ data.tar.gz: 90feb73a98cd846639b42189bb67bf4ebf65e41976fef99b2405e21ecb4e5762
5
5
  SHA512:
6
- metadata.gz: 01feecb156624a79c91954ba5527948e962ff6d64e9a8aaa2906e4afb633bf62e82527c8a6a9c75444782ec383b0918828c77ffb80e9ac68b08abe68c168859e
7
- data.tar.gz: 861150c54a6f3af9964af691dce4a43bb7cfcc48a6f8963ba82f5f7e76762f0b7ee5d94193f6f76283978713e4968d0aae56ae8c93cbc87845bb0a329a7f1056
6
+ metadata.gz: a5e959366dfb2e2fe68fb9f2cdaf64bb60e855597b5019d6363ba3ee83671fc2e082f2d022e7f295acf269d3beef321395369029b54075940254d5c1f75b8575
7
+ data.tar.gz: d26de938c9b5b89d33bb4361c66a5f2048f08301fc805fe13fe5061c8f451c5d3e98e227c8501cd4551bbb5c0b3b3b5959894654dcec76a41f10ac4c46e03a8c
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
@@ -33,6 +33,8 @@ require 'linear_api'
33
33
  LinearApi.configure do |config|
34
34
  config.api_key = ENV['LINEAR_API_KEY']
35
35
  config.team_id = 'your-team-id' # e.g., 'cfe09c0a-dcae-4cf9-be66-621d00798fcf'
36
+ config.workspace_slug = 'your-workspace' # Used for building issue URLs (default: 'theownerstack')
37
+ config.logger = Rails.logger # Optional: defaults to Rails.logger or stdout
36
38
  end
37
39
  ```
38
40
 
@@ -45,7 +47,9 @@ result = LinearApi.client.create_issue(
45
47
  title: 'Bug: Login fails with special characters',
46
48
  description: '## Problem\n\nUsers cannot login when password contains @',
47
49
  priority: 2, # 1=Urgent, 2=High, 3=Medium, 4=Low
48
- label_ids: ['752f6bfb-9943-4dde-8d36-63b43c34c12f'] # type:bug
50
+ label_ids: ['752f6bfb-9943-4dde-8d36-63b43c34c12f'],
51
+ due_date: '2026-03-01', # ISO 8601 date
52
+ estimate: 3 # Story points
49
53
  )
50
54
 
51
55
  if result.success?
@@ -54,11 +58,16 @@ if result.success?
54
58
  else
55
59
  puts "Error: #{result.error}"
56
60
  end
61
+
62
+ # Or use value! to raise on failure:
63
+ issue = LinearApi.client.create_issue(title: 'Bug').value!
64
+ puts issue.identifier
57
65
  ```
58
66
 
59
67
  ### Get an Issue
60
68
 
61
69
  ```ruby
70
+ # Direct lookup by identifier (exact match)
62
71
  result = LinearApi.client.get_issue('TOS-123')
63
72
 
64
73
  if result.success?
@@ -66,6 +75,18 @@ if result.success?
66
75
  puts "Title: #{issue.title}"
67
76
  puts "State: #{issue.state_name}"
68
77
  puts "Labels: #{issue.label_names.join(', ')}"
78
+ puts "Due: #{issue.due_date}"
79
+ puts "Estimate: #{issue.estimate}"
80
+ end
81
+ ```
82
+
83
+ ### Search Issues (Full-Text)
84
+
85
+ ```ruby
86
+ result = LinearApi.client.search_issues(term: 'login bug', limit: 10)
87
+
88
+ result.data.each do |issue|
89
+ puts "#{issue.identifier}: #{issue.title}"
69
90
  end
70
91
  ```
71
92
 
@@ -97,11 +118,31 @@ result = LinearApi.client.add_comment(
97
118
  ### List Issues
98
119
 
99
120
  ```ruby
121
+ # Simple list (up to 50)
100
122
  result = LinearApi.client.list_issues(limit: 50)
101
123
 
102
124
  result.data.each do |issue|
103
125
  puts "#{issue.identifier}: #{issue.title} [#{issue.state_name}]"
104
126
  end
127
+
128
+ # List ALL issues with automatic pagination
129
+ result = LinearApi.client.list_all_issues(batch_size: 50) do |issue|
130
+ # Optional: process each issue as it's fetched
131
+ puts "Fetched: #{issue.identifier}"
132
+ end
133
+
134
+ puts "Total: #{result.data.length}"
135
+ ```
136
+
137
+ ### Batch Fetch Issues
138
+
139
+ ```ruby
140
+ # Fetch multiple issues in a single API call (avoids N+1)
141
+ result = LinearApi.client.batch_get_issues(ids: ['uuid-1', 'uuid-2', 'uuid-3'])
142
+
143
+ result.data.each do |issue|
144
+ puts "#{issue.identifier}: #{issue.state_name}"
145
+ end
105
146
  ```
106
147
 
107
148
  ### List Labels
@@ -168,6 +209,20 @@ end
168
209
  LinearApi.client.assign(id: issue.id, assignee_id: 'user-uuid')
169
210
  ```
170
211
 
212
+ ### Fetch All Metadata (Single API Call)
213
+
214
+ ```ruby
215
+ # Fetch labels, projects, states, and users in one batched call
216
+ result = LinearApi.client.fetch_all_metadata
217
+
218
+ if result.success?
219
+ puts "Labels: #{result.data[:labels].length}"
220
+ puts "Projects: #{result.data[:projects].length}"
221
+ puts "States: #{result.data[:states].length}"
222
+ puts "Users: #{result.data[:users].length}"
223
+ end
224
+ ```
225
+
171
226
  ### Archive/Unarchive Issues
172
227
 
173
228
  ```ruby
@@ -230,14 +285,31 @@ puts result.data['viewer']['name']
230
285
  ```ruby
231
286
  result = LinearApi.client.create_issue(title: 'Test')
232
287
 
233
- case result
234
- when ->(r) { r.success? }
235
- # Handle success
288
+ if result.success?
236
289
  puts result.data.identifier
237
- when ->(r) { r.failure? }
238
- # Handle error
290
+ else
239
291
  puts "Error: #{result.error}"
240
292
  end
293
+
294
+ # Or use value! to raise on failure (good for scripts)
295
+ begin
296
+ issue = LinearApi.client.create_issue(title: 'Test').value!
297
+ puts issue.identifier
298
+ rescue LinearApi::Error => e
299
+ puts "Failed: #{e.message}"
300
+ rescue LinearApi::RateLimitError => e
301
+ puts "Rate limited, retry after #{e.retry_after}s"
302
+ end
303
+ ```
304
+
305
+ ## Health Check
306
+
307
+ ```ruby
308
+ if LinearApi.healthy?
309
+ puts "Linear API is reachable"
310
+ else
311
+ puts "Cannot connect to Linear"
312
+ end
241
313
  ```
242
314
 
243
315
  ## Rails Engine (Auto-Tracking)
@@ -247,10 +319,13 @@ The gem includes a Rails Engine for automatic error tracking with deduplication.
247
319
  ### Setup
248
320
 
249
321
  ```bash
322
+ # Verify Linear API connectivity
323
+ bin/rails linear:health
324
+
250
325
  # Create the auto-tracking project and label in Linear
251
326
  bin/rails linear:setup
252
327
 
253
- # Sync labels, projects, and states from Linear
328
+ # Sync labels, projects, and states from Linear (single batched API call)
254
329
  bin/rails linear:sync_cache
255
330
  ```
256
331
 
@@ -262,7 +337,7 @@ class DailySalesReportJob < ApplicationJob
262
337
  def perform(account)
263
338
  # ... generate report ...
264
339
  rescue StandardError => e
265
- # Track the error in Linear
340
+ # Track the error in Linear (never raises, returns Result)
266
341
  LinearApi::IssueTracker.track_error(
267
342
  exception: e,
268
343
  context: { date: Date.current, account_id: account.id },
@@ -287,6 +362,21 @@ LinearApi::IssueTracker.track_issue(
287
362
  )
288
363
  ```
289
364
 
365
+ ### Security: Sensitive Data Redaction
366
+
367
+ `IssueTracker` automatically redacts context values whose keys match sensitive patterns (password, secret, token, api_key, credential, authorization):
368
+
369
+ ```ruby
370
+ LinearApi::IssueTracker.track_error(
371
+ exception: e,
372
+ context: {
373
+ account_id: 123, # Included as-is
374
+ api_key: 'sk_live_xxx', # Redacted to '[REDACTED]'
375
+ user_email: 'user@test.com' # Included as-is
376
+ }
377
+ )
378
+ ```
379
+
290
380
  ### How Deduplication Works
291
381
 
292
382
  1. Each error generates a fingerprint based on exception class, message, and backtrace
@@ -296,13 +386,16 @@ LinearApi::IssueTracker.track_issue(
296
386
  ### Rake Tasks
297
387
 
298
388
  ```bash
389
+ # Verify API connectivity
390
+ bin/rails linear:health
391
+
299
392
  # Setup auto-tracking project and label
300
393
  bin/rails linear:setup
301
394
 
302
- # Sync metadata from Linear API
395
+ # Sync metadata from Linear API (single batched call)
303
396
  bin/rails linear:sync_cache
304
397
 
305
- # Refresh local issue states (check if resolved)
398
+ # Refresh local issue states (batched, checks if resolved)
306
399
  bin/rails linear:refresh_issues
307
400
 
308
401
  # Show cache statistics
@@ -344,9 +437,18 @@ account_issues = LinearApi::SyncedIssue.where(
344
437
  # Check if an issue is still open
345
438
  issue = LinearApi::SyncedIssue.find_by(identifier: 'TOS-123')
346
439
  puts issue.open? # => true/false
347
- puts issue.linear_url # => "https://linear.app/theownerstack/issue/TOS-123"
440
+ puts issue.linear_url # => "https://linear.app/your-workspace/issue/TOS-123"
348
441
  ```
349
442
 
443
+ ## Resilience Features
444
+
445
+ - **Connection timeouts**: 5s connect, 15s read (configurable per-client)
446
+ - **Automatic retries**: 3 retries with exponential backoff on 500/502/503 and network errors
447
+ - **Rate limit detection**: HTTP 429 raises `RateLimitError` with `retry_after`
448
+ - **Thread safety**: Module-level client access is protected by `Mutex`
449
+ - **Logging**: All API calls, retries, and errors logged via configurable `LinearApi.logger`
450
+ - **Silent error tracking**: `IssueTracker.track_error` never raises -- returns failure `Result` and logs
451
+
350
452
  ## Development
351
453
 
352
454
  ```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