linear_api 0.3.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6a4003a10fb0279c739bb5cadef86f1d953ada25452b33bc274cc50c1ac14df9
4
+ data.tar.gz: 5b1e90306471473e8983634c34ea0d4543ab9fd987be9e9917da10d88ae68731
5
+ SHA512:
6
+ metadata.gz: cfec50c4d35241ed7923ae427a55702e970aaa734d5d2cfd3d51ca7a07cf9667908a5084a997418faff6d56f64b3196f6908bd28296003d53cad0ccdfcfd07d9
7
+ data.tar.gz: 58171286e1925c8b665b1be6f09c5525761a7708abe8390c53b3897bff2bae12f206df8a7ca117fa7da8deb389bad906a70ec8bcad0027120b3e1b1d8484fbdf
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.2.0] - 2026-02-02
9
+
10
+ ### Added
11
+
12
+ - `list_users` - List team members for assignment
13
+ - `archive_issue` - Archive (soft delete) an issue
14
+ - `unarchive_issue` - Restore an archived issue
15
+ - `create_label` - Create new labels programmatically
16
+ - `create_sub_issue` - Create child issues
17
+ - `list_sub_issues` - List children of an issue
18
+
19
+ ## [0.1.0] - 2026-02-01
20
+
21
+ ### Added
22
+
23
+ - Initial release
24
+ - `LinearApi::Client` for GraphQL API communication
25
+ - Issue management: create, update, get, list
26
+ - Comment support for issues
27
+ - Project and label listing
28
+ - Workflow state querying
29
+ - `LinearApi::Result` for consistent response handling
30
+ - RSpec test suite with WebMock
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 TheOwnerStack
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,371 @@
1
+ # LinearApi
2
+
3
+ A Ruby gem for interacting with the [Linear](https://linear.app) GraphQL API. Create, update, and query issues, projects, and labels programmatically. Includes a Rails Engine for automatic error tracking with deduplication.
4
+
5
+ ## Installation
6
+
7
+ Add this line to your application's Gemfile:
8
+
9
+ ```ruby
10
+ gem 'linear_api', path: '../linear'
11
+ # Or from GitHub:
12
+ gem 'linear_api', git: 'https://github.com/dan1d/linear_api.git'
13
+ ```
14
+
15
+ Then run:
16
+
17
+ ```bash
18
+ bundle install
19
+ ```
20
+
21
+ For Rails applications, run the migrations:
22
+
23
+ ```bash
24
+ bin/rails linear_api:install:migrations
25
+ bin/rails db:migrate
26
+ ```
27
+
28
+ ## Configuration
29
+
30
+ ```ruby
31
+ require 'linear_api'
32
+
33
+ LinearApi.configure do |config|
34
+ config.api_key = ENV['LINEAR_API_KEY']
35
+ config.team_id = 'your-team-id' # e.g., 'cfe09c0a-dcae-4cf9-be66-621d00798fcf'
36
+ end
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ### Create an Issue
42
+
43
+ ```ruby
44
+ result = LinearApi.client.create_issue(
45
+ title: 'Bug: Login fails with special characters',
46
+ description: '## Problem\n\nUsers cannot login when password contains @',
47
+ priority: 2, # 1=Urgent, 2=High, 3=Medium, 4=Low
48
+ label_ids: ['752f6bfb-9943-4dde-8d36-63b43c34c12f'] # type:bug
49
+ )
50
+
51
+ if result.success?
52
+ puts "Created: #{result.data.identifier}" # => "TOS-123"
53
+ puts "URL: #{result.data.url}"
54
+ else
55
+ puts "Error: #{result.error}"
56
+ end
57
+ ```
58
+
59
+ ### Get an Issue
60
+
61
+ ```ruby
62
+ result = LinearApi.client.get_issue('TOS-123')
63
+
64
+ if result.success?
65
+ issue = result.data
66
+ puts "Title: #{issue.title}"
67
+ puts "State: #{issue.state_name}"
68
+ puts "Labels: #{issue.label_names.join(', ')}"
69
+ end
70
+ ```
71
+
72
+ ### Update an Issue
73
+
74
+ ```ruby
75
+ # Update state
76
+ result = LinearApi.client.update_issue(
77
+ id: 'issue-uuid',
78
+ state_id: 'state-uuid'
79
+ )
80
+
81
+ # Add labels
82
+ result = LinearApi.client.update_issue(
83
+ id: 'issue-uuid',
84
+ label_ids: ['label-1', 'label-2']
85
+ )
86
+ ```
87
+
88
+ ### Add a Comment
89
+
90
+ ```ruby
91
+ result = LinearApi.client.add_comment(
92
+ issue_id: 'issue-uuid',
93
+ body: '## QA Results\n\n✅ All tests passed'
94
+ )
95
+ ```
96
+
97
+ ### List Issues
98
+
99
+ ```ruby
100
+ result = LinearApi.client.list_issues(limit: 50)
101
+
102
+ result.data.each do |issue|
103
+ puts "#{issue.identifier}: #{issue.title} [#{issue.state_name}]"
104
+ end
105
+ ```
106
+
107
+ ### List Labels
108
+
109
+ ```ruby
110
+ result = LinearApi.client.list_labels
111
+
112
+ result.data.each do |label|
113
+ puts "#{label.name}: #{label.id}"
114
+ end
115
+ ```
116
+
117
+ ### Projects
118
+
119
+ ```ruby
120
+ # List all projects
121
+ result = LinearApi.client.list_projects
122
+
123
+ result.data.each do |project|
124
+ puts "#{project.name}: #{project.id}"
125
+ end
126
+
127
+ # Create a project
128
+ result = LinearApi.client.create_project(
129
+ name: 'Test Coverage and Automation',
130
+ description: 'Track QA automation and test coverage improvements',
131
+ color: '#4338ca'
132
+ )
133
+
134
+ if result.success?
135
+ puts "Created project: #{result.data.name}"
136
+ puts "URL: #{result.data.url}"
137
+ end
138
+
139
+ # Add an issue to a project
140
+ result = LinearApi.client.create_issue(
141
+ title: 'Add integration tests for payment flow',
142
+ description: '## Objective\n\nImprove test coverage for payment processing',
143
+ project_id: 'project-uuid',
144
+ priority: 2
145
+ )
146
+ ```
147
+
148
+ ### Get Workflow States
149
+
150
+ ```ruby
151
+ result = LinearApi.client.list_states
152
+
153
+ result.data.each do |state|
154
+ puts "#{state['name']} (#{state['type']}): #{state['id']}"
155
+ end
156
+ ```
157
+
158
+ ### List Team Members (for Assignment)
159
+
160
+ ```ruby
161
+ result = LinearApi.client.list_users
162
+
163
+ result.data.each do |user|
164
+ puts "#{user['name']} (#{user['email']}): #{user['id']}"
165
+ end
166
+
167
+ # Assign to a user
168
+ LinearApi.client.assign(id: issue.id, assignee_id: 'user-uuid')
169
+ ```
170
+
171
+ ### Archive/Unarchive Issues
172
+
173
+ ```ruby
174
+ # Archive (soft delete)
175
+ LinearApi.client.archive_issue(id: 'issue-uuid')
176
+
177
+ # Restore
178
+ LinearApi.client.unarchive_issue(id: 'issue-uuid')
179
+ ```
180
+
181
+ ### Create Labels
182
+
183
+ ```ruby
184
+ result = LinearApi.client.create_label(
185
+ name: 'priority:critical',
186
+ color: '#ff0000',
187
+ description: 'Critical priority issues'
188
+ )
189
+
190
+ puts "Created label: #{result.data.id}"
191
+ ```
192
+
193
+ ### Sub-Issues
194
+
195
+ ```ruby
196
+ # Create a sub-issue
197
+ result = LinearApi.client.create_sub_issue(
198
+ parent_id: 'parent-issue-uuid',
199
+ title: 'Sub-task: Write tests',
200
+ priority: 3
201
+ )
202
+
203
+ # List sub-issues
204
+ children = LinearApi.client.list_sub_issues(parent_id: 'parent-issue-uuid')
205
+ children.data.each do |child|
206
+ puts "#{child.identifier}: #{child.title}"
207
+ end
208
+ ```
209
+
210
+ ## Direct GraphQL Queries
211
+
212
+ For advanced use cases, execute raw GraphQL:
213
+
214
+ ```ruby
215
+ query = <<~GRAPHQL
216
+ query {
217
+ viewer {
218
+ name
219
+ email
220
+ }
221
+ }
222
+ GRAPHQL
223
+
224
+ result = LinearApi.client.query(query)
225
+ puts result.data['viewer']['name']
226
+ ```
227
+
228
+ ## Error Handling
229
+
230
+ ```ruby
231
+ result = LinearApi.client.create_issue(title: 'Test')
232
+
233
+ case result
234
+ when ->(r) { r.success? }
235
+ # Handle success
236
+ puts result.data.identifier
237
+ when ->(r) { r.failure? }
238
+ # Handle error
239
+ puts "Error: #{result.error}"
240
+ end
241
+ ```
242
+
243
+ ## Rails Engine (Auto-Tracking)
244
+
245
+ The gem includes a Rails Engine for automatic error tracking with deduplication. Errors with the same fingerprint are grouped together instead of creating duplicate issues.
246
+
247
+ ### Setup
248
+
249
+ ```bash
250
+ # Create the auto-tracking project and label in Linear
251
+ bin/rails linear:setup
252
+
253
+ # Sync labels, projects, and states from Linear
254
+ bin/rails linear:sync_cache
255
+ ```
256
+
257
+ ### Track Errors Automatically
258
+
259
+ ```ruby
260
+ # In your service or job
261
+ class DailySalesReportJob < ApplicationJob
262
+ def perform(account)
263
+ # ... generate report ...
264
+ rescue StandardError => e
265
+ # Track the error in Linear
266
+ LinearApi::IssueTracker.track_error(
267
+ exception: e,
268
+ context: { date: Date.current, account_id: account.id },
269
+ source: account,
270
+ labels: [:bug, :sync]
271
+ )
272
+ raise # Re-raise after tracking
273
+ end
274
+ end
275
+ ```
276
+
277
+ ### Track Custom Issues
278
+
279
+ ```ruby
280
+ # Create a tracked issue (not from an exception)
281
+ LinearApi::IssueTracker.track_issue(
282
+ title: 'Missing API credentials for merchant',
283
+ description: "Merchant #{merchant.name} has invalid Clover credentials",
284
+ context: { merchant_id: merchant.id },
285
+ source: merchant,
286
+ labels: [:bug, :area_sync]
287
+ )
288
+ ```
289
+
290
+ ### How Deduplication Works
291
+
292
+ 1. Each error generates a fingerprint based on exception class, message, and backtrace
293
+ 2. If an open issue exists with the same fingerprint, a comment is added instead
294
+ 3. Once an issue is marked "Done" or "Canceled", new occurrences create a fresh issue
295
+
296
+ ### Rake Tasks
297
+
298
+ ```bash
299
+ # Setup auto-tracking project and label
300
+ bin/rails linear:setup
301
+
302
+ # Sync metadata from Linear API
303
+ bin/rails linear:sync_cache
304
+
305
+ # Refresh local issue states (check if resolved)
306
+ bin/rails linear:refresh_issues
307
+
308
+ # Show cache statistics
309
+ bin/rails linear:stats
310
+
311
+ # Export cached metadata to JSON
312
+ bin/rails linear:export_cache
313
+ ```
314
+
315
+ ### Cached Metadata
316
+
317
+ The engine caches Linear metadata locally for fast lookups:
318
+
319
+ ```ruby
320
+ # Find a label by name
321
+ label = LinearApi::CachedMetadata.labels.find_by(name: 'type:bug')
322
+
323
+ # Get all projects
324
+ projects = LinearApi::CachedMetadata.projects.pluck(:name, :linear_id)
325
+
326
+ # Resolve label ID from key
327
+ bug_id = LinearApi::CachedMetadata.resolve_label_id('type:bug')
328
+ ```
329
+
330
+ ### Synced Issues
331
+
332
+ Track which issues have been created:
333
+
334
+ ```ruby
335
+ # Find all unresolved tracked issues
336
+ open_issues = LinearApi::SyncedIssue.unresolved
337
+
338
+ # Find issues for a specific source
339
+ account_issues = LinearApi::SyncedIssue.where(
340
+ source_type: 'CloverAccount',
341
+ source_id: account.id
342
+ )
343
+
344
+ # Check if an issue is still open
345
+ issue = LinearApi::SyncedIssue.find_by(identifier: 'TOS-123')
346
+ puts issue.open? # => true/false
347
+ puts issue.linear_url # => "https://linear.app/theownerstack/issue/TOS-123"
348
+ ```
349
+
350
+ ## Development
351
+
352
+ ```bash
353
+ # Install dependencies
354
+ bundle install
355
+
356
+ # Run tests
357
+ bundle exec rspec
358
+
359
+ # Run tests with VCR recording (hits live API)
360
+ VCR_RECORD=1 bundle exec rspec
361
+
362
+ # Run linter
363
+ bundle exec rubocop
364
+
365
+ # Run all checks
366
+ bundle exec rake
367
+ ```
368
+
369
+ ## License
370
+
371
+ MIT License. See LICENSE.txt.
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ self.table_name_prefix = 'linear_api_'
7
+ end
8
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ class CachedMetadata < ApplicationRecord
5
+ TYPES = %w[label project state user].freeze
6
+
7
+ validates :metadata_type, presence: true, inclusion: { in: TYPES }
8
+ validates :linear_id, presence: true, uniqueness: { scope: :metadata_type }
9
+
10
+ # Type scopes
11
+ scope :labels, -> { where(metadata_type: 'label') }
12
+ scope :projects, -> { where(metadata_type: 'project') }
13
+ scope :states, -> { where(metadata_type: 'state') }
14
+ scope :users, -> { where(metadata_type: 'user') }
15
+
16
+ # Category scopes for labels
17
+ scope :by_category, ->(cat) { where(category: cat) }
18
+ scope :type_labels, -> { labels.by_category('type') }
19
+ scope :area_labels, -> { labels.by_category('area') }
20
+ scope :priority_labels, -> { labels.by_category('priority') }
21
+
22
+ # State type scopes
23
+ scope :completed_states, -> { states.where(state_type: 'completed') }
24
+ scope :active_states, -> { states.where(state_type: %w[unstarted started]) }
25
+
26
+ class << self
27
+ # Common label lookups
28
+ def bug_label_id
29
+ labels.find_by(key: 'type:bug')&.linear_id
30
+ end
31
+
32
+ def feature_label_id
33
+ labels.find_by(key: 'type:feature')&.linear_id
34
+ end
35
+
36
+ def sync_label_id
37
+ labels.find_by(key: 'area:sync')&.linear_id
38
+ end
39
+
40
+ def infrastructure_label_id
41
+ labels.find_by(key: 'area:infrastructure')&.linear_id
42
+ end
43
+
44
+ def critical_priority_id
45
+ labels.find_by(key: 'priority:critical')&.linear_id
46
+ end
47
+
48
+ def high_priority_id
49
+ labels.find_by(key: 'priority:high')&.linear_id
50
+ end
51
+
52
+ # State lookups
53
+ def backlog_state_id
54
+ states.find_by(key: 'backlog')&.linear_id
55
+ end
56
+
57
+ def todo_state_id
58
+ states.find_by(key: 'todo')&.linear_id
59
+ end
60
+
61
+ def in_progress_state_id
62
+ states.find_by(key: 'in_progress')&.linear_id
63
+ end
64
+
65
+ def done_state_id
66
+ states.find_by(key: 'done')&.linear_id
67
+ end
68
+
69
+ # Project lookups
70
+ def daily_operations_project_id
71
+ projects.find_by(key: 'daily_operations')&.linear_id
72
+ end
73
+
74
+ def tech_debt_project_id
75
+ projects.find_by(key: 'tech_debt')&.linear_id
76
+ end
77
+
78
+ # Resolve symbolic label to ID
79
+ def resolve_label_id(label)
80
+ case label
81
+ when :bug then bug_label_id
82
+ when :feature then feature_label_id
83
+ when :sync then sync_label_id
84
+ when :infrastructure then infrastructure_label_id
85
+ when :critical then critical_priority_id
86
+ when :high then high_priority_id
87
+ when String then label # Already an ID
88
+ else
89
+ labels.find_by(key: label.to_s)&.linear_id
90
+ end
91
+ end
92
+
93
+ # Upsert from API data
94
+ def upsert_from_api(type:, linear_id:, name:, **attrs)
95
+ find_or_initialize_by(metadata_type: type, linear_id: linear_id).tap do |record|
96
+ record.assign_attributes(name: name, **attrs)
97
+ record.save!
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ class SyncedIssue < ApplicationRecord
5
+ validates :linear_id, presence: true, uniqueness: true
6
+ validates :fingerprint, presence: true
7
+
8
+ # Source polymorphism (without actual belongs_to for flexibility)
9
+ scope :for_source, ->(type, id) { where(source_type: type.to_s, source_id: id.to_s) }
10
+ scope :by_fingerprint, ->(fp) { where(fingerprint: fp) }
11
+
12
+ # State scopes
13
+ scope :unresolved, -> { where.not(state_name: %w[Done Canceled Duplicate]) }
14
+ scope :resolved, -> { where(state_name: 'Done') }
15
+ scope :open, -> { where(state_name: %w[Backlog Todo]) }
16
+ scope :in_progress, -> { where(state_name: ['In Progress', 'In Review', 'QA']) }
17
+
18
+ # Time scopes
19
+ scope :recent, -> { where('created_at > ?', 7.days.ago) }
20
+ scope :stale, -> { unresolved.where('synced_at < ?', 1.day.ago) }
21
+
22
+ # Check if this issue is still open in Linear
23
+ def open?
24
+ !%w[Done Canceled Duplicate].include?(state_name)
25
+ end
26
+
27
+ def resolved?
28
+ state_name == 'Done'
29
+ end
30
+
31
+ # Sync state from Linear
32
+ def refresh_from_linear!
33
+ result = LinearApi.client.get_issue_by_id(linear_id)
34
+ return false unless result.success?
35
+
36
+ update!(
37
+ state_name: result.data.state_name,
38
+ title: result.data.title,
39
+ priority: result.data.priority,
40
+ synced_at: Time.current
41
+ )
42
+ true
43
+ end
44
+
45
+ # Build Linear URL
46
+ def linear_url
47
+ "https://linear.app/theownerstack/issue/#{identifier}"
48
+ end
49
+
50
+ # Parse metadata JSON
51
+ def parsed_metadata
52
+ return {} unless metadata
53
+
54
+ metadata.is_a?(String) ? JSON.parse(metadata) : metadata
55
+ rescue JSON::ParserError
56
+ {}
57
+ end
58
+
59
+ class << self
60
+ # Find existing open issue for this fingerprint
61
+ def find_open_for_fingerprint(fingerprint)
62
+ by_fingerprint(fingerprint).unresolved.order(created_at: :desc).first
63
+ end
64
+
65
+ # Check if we should create a new issue or add to existing
66
+ def should_create_new?(fingerprint)
67
+ find_open_for_fingerprint(fingerprint).nil?
68
+ end
69
+
70
+ # Refresh all stale issues
71
+ def refresh_stale_issues!
72
+ stale.find_each(&:refresh_from_linear!)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateLinearApiCachedMetadata < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :linear_api_cached_metadata do |t|
6
+ t.string :metadata_type, null: false # "label", "project", "state", "user"
7
+ t.string :linear_id, null: false
8
+ t.string :name
9
+ t.string :key # For lookup (e.g., "type:bug", "area:sync", "backlog")
10
+ t.string :color
11
+ t.string :category # For labels: "type", "area", "priority", "qa"
12
+ t.string :state_type # For states: "backlog", "unstarted", "started", "completed", "canceled"
13
+ t.json :raw_data
14
+
15
+ t.timestamps
16
+ end
17
+
18
+ add_index :linear_api_cached_metadata, %i[metadata_type linear_id], unique: true, name: 'idx_linear_cached_metadata_type_id'
19
+ add_index :linear_api_cached_metadata, %i[metadata_type key], name: 'idx_linear_cached_metadata_type_key'
20
+ add_index :linear_api_cached_metadata, :category
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateLinearApiSyncedIssues < ActiveRecord::Migration[7.0]
4
+ def change
5
+ create_table :linear_api_synced_issues do |t|
6
+ t.string :linear_id, null: false
7
+ t.string :identifier # TOS-123
8
+ t.string :title
9
+ t.string :state_name
10
+ t.integer :priority
11
+ t.string :source_type # "AccountLinking", "DailySalesReport", etc.
12
+ t.string :source_id # ID of the related record
13
+ t.string :fingerprint, null: false # For deduplication
14
+ t.json :metadata # Extra context (date, error details, etc.)
15
+ t.datetime :synced_at
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ add_index :linear_api_synced_issues, :linear_id, unique: true
21
+ add_index :linear_api_synced_issues, :identifier
22
+ add_index :linear_api_synced_issues, :fingerprint
23
+ add_index :linear_api_synced_issues, %i[source_type source_id], name: 'idx_linear_synced_issues_source'
24
+ add_index :linear_api_synced_issues, :state_name
25
+ end
26
+ end