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.
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace LinearApi
6
+
7
+ config.generators do |g|
8
+ g.test_framework :rspec
9
+ g.fixture_replacement :factory_bot
10
+ g.factory_bot dir: 'spec/factories'
11
+ end
12
+
13
+ # Load rake tasks
14
+ rake_tasks do
15
+ load File.expand_path('../tasks/linear_api.rake', __dir__)
16
+ end
17
+
18
+ initializer 'linear_api.configure' do
19
+ # Auto-configure from environment variables if not already set
20
+ LinearApi.api_key ||= ENV['LINEAR_API_KEY']
21
+ LinearApi.team_id ||= ENV['LINEAR_TEAM_ID']
22
+ end
23
+
24
+ # Eager load models in production
25
+ config.eager_load_namespaces << LinearApi
26
+ end
27
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ # Represents a Linear issue
5
+ class Issue
6
+ CREATE_MUTATION = <<~GRAPHQL
7
+ mutation CreateIssue($input: IssueCreateInput!) {
8
+ issueCreate(input: $input) {
9
+ success
10
+ issue {
11
+ id
12
+ identifier
13
+ title
14
+ url
15
+ state {
16
+ id
17
+ name
18
+ }
19
+ }
20
+ }
21
+ }
22
+ GRAPHQL
23
+
24
+ UPDATE_MUTATION = <<~GRAPHQL
25
+ mutation UpdateIssue($id: String!, $input: IssueUpdateInput!) {
26
+ issueUpdate(id: $id, input: $input) {
27
+ success
28
+ issue {
29
+ id
30
+ identifier
31
+ title
32
+ state {
33
+ id
34
+ name
35
+ }
36
+ labels {
37
+ nodes {
38
+ id
39
+ name
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ GRAPHQL
46
+
47
+ SEARCH_QUERY = <<~GRAPHQL
48
+ query GetIssue($term: String!) {
49
+ searchIssues(term: $term, first: 1) {
50
+ nodes {
51
+ id
52
+ identifier
53
+ title
54
+ description
55
+ url
56
+ priority
57
+ state {
58
+ id
59
+ name
60
+ type
61
+ }
62
+ labels {
63
+ nodes {
64
+ id
65
+ name
66
+ }
67
+ }
68
+ assignee {
69
+ id
70
+ name
71
+ }
72
+ project {
73
+ id
74
+ name
75
+ }
76
+ comments {
77
+ nodes {
78
+ id
79
+ body
80
+ createdAt
81
+ user {
82
+ name
83
+ }
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ GRAPHQL
90
+
91
+ LIST_QUERY = <<~GRAPHQL
92
+ query ListIssues($teamId: String!, $first: Int!) {
93
+ team(id: $teamId) {
94
+ issues(first: $first, orderBy: updatedAt) {
95
+ nodes {
96
+ id
97
+ identifier
98
+ title
99
+ priority
100
+ state {
101
+ id
102
+ name
103
+ type
104
+ }
105
+ labels {
106
+ nodes {
107
+ id
108
+ name
109
+ }
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ GRAPHQL
116
+
117
+ ADD_COMMENT_MUTATION = <<~GRAPHQL
118
+ mutation AddComment($issueId: String!, $body: String!) {
119
+ commentCreate(input: { issueId: $issueId, body: $body }) {
120
+ success
121
+ comment {
122
+ id
123
+ body
124
+ createdAt
125
+ }
126
+ }
127
+ }
128
+ GRAPHQL
129
+
130
+ attr_reader :id, :identifier, :title, :description, :url, :priority,
131
+ :state, :labels, :assignee, :project, :comments, :raw
132
+
133
+ def initialize(data)
134
+ @raw = data
135
+ @id = data['id']
136
+ @identifier = data['identifier']
137
+ @title = data['title']
138
+ @description = data['description']
139
+ @url = data['url']
140
+ @priority = data['priority']
141
+ @state = data['state']
142
+ @labels = (data.dig('labels', 'nodes') || [])
143
+ @assignee = data['assignee']
144
+ @project = data['project']
145
+ @comments = (data.dig('comments', 'nodes') || [])
146
+ end
147
+
148
+ def state_name
149
+ state&.dig('name')
150
+ end
151
+
152
+ def label_names
153
+ labels.map { |l| l['name'] }
154
+ end
155
+
156
+ def to_h
157
+ {
158
+ id: id,
159
+ identifier: identifier,
160
+ title: title,
161
+ description: description,
162
+ url: url,
163
+ priority: priority,
164
+ state: state_name,
165
+ labels: label_names
166
+ }
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ class IssueTracker
5
+ DEFAULT_LABELS = %i[bug sync].freeze
6
+ AUTO_TRACKED_PROJECT_NAME = 'Auto-Tracked Errors'
7
+ AUTO_TRACKED_PROJECT_KEY = 'auto_tracked_errors'
8
+ AUTO_TRACKED_LABEL_NAME = 'auto:tracked'
9
+ AUTO_TRACKED_LABEL_COLOR = '#6b7280' # Gray
10
+
11
+ class << self
12
+ # Track an error and create/update a Linear issue
13
+ #
14
+ # @param exception [Exception] The exception to track
15
+ # @param context [Hash] Additional context (date, IDs, etc.)
16
+ # @param source [Object] The source object (AccountLinking, etc.)
17
+ # @param labels [Array<Symbol, String>] Labels to apply (:bug, :sync, :critical, or IDs)
18
+ # @return [LinearApi::Result, LinearApi::SyncedIssue] Result or existing issue
19
+ def track_error(exception:, context: {}, source: nil, labels: DEFAULT_LABELS)
20
+ fingerprint = generate_fingerprint(exception, source)
21
+
22
+ # Check for existing open issue
23
+ existing = SyncedIssue.find_open_for_fingerprint(fingerprint)
24
+ return add_occurrence_comment(existing, exception, context) if existing
25
+
26
+ # Ensure auto-tracked project and label exist
27
+ project_id = ensure_auto_tracked_project
28
+ auto_label_id = ensure_auto_tracked_label
29
+
30
+ # Resolve label IDs from cache + add auto:tracked label
31
+ label_ids = resolve_label_ids(labels)
32
+ label_ids << auto_label_id if auto_label_id
33
+
34
+ # Create new Linear issue
35
+ result = LinearApi.client.create_issue(
36
+ title: build_title(exception),
37
+ description: build_description(exception, context, source),
38
+ priority: determine_priority(exception, context),
39
+ label_ids: label_ids.compact.uniq,
40
+ project_id: project_id
41
+ )
42
+
43
+ return result unless result.success?
44
+
45
+ # Track locally
46
+ synced_issue = SyncedIssue.create!(
47
+ linear_id: result.data.id,
48
+ identifier: result.data.identifier,
49
+ title: result.data.title,
50
+ state_name: 'Backlog',
51
+ priority: determine_priority(exception, context),
52
+ fingerprint: fingerprint,
53
+ source_type: source&.class&.name,
54
+ source_id: source&.id&.to_s,
55
+ metadata: context.to_json,
56
+ synced_at: Time.current
57
+ )
58
+
59
+ Result.new(success: true, data: synced_issue)
60
+ end
61
+
62
+ # Track a custom issue (not from exception)
63
+ def track_issue(title:, description:, context: {}, source: nil, labels: [], priority: 3)
64
+ fingerprint = generate_custom_fingerprint(title, source)
65
+
66
+ existing = SyncedIssue.find_open_for_fingerprint(fingerprint)
67
+ return Result.new(success: true, data: existing) if existing
68
+
69
+ # Ensure auto-tracked project and label exist
70
+ project_id = ensure_auto_tracked_project
71
+ auto_label_id = ensure_auto_tracked_label
72
+
73
+ # Resolve label IDs + add auto:tracked label
74
+ label_ids = resolve_label_ids(labels)
75
+ label_ids << auto_label_id if auto_label_id
76
+
77
+ result = LinearApi.client.create_issue(
78
+ title: "[Auto] #{title}",
79
+ description: description,
80
+ priority: priority,
81
+ label_ids: label_ids.compact.uniq,
82
+ project_id: project_id
83
+ )
84
+
85
+ return result unless result.success?
86
+
87
+ synced_issue = SyncedIssue.create!(
88
+ linear_id: result.data.id,
89
+ identifier: result.data.identifier,
90
+ title: result.data.title,
91
+ state_name: 'Backlog',
92
+ priority: priority,
93
+ fingerprint: fingerprint,
94
+ source_type: source&.class&.name,
95
+ source_id: source&.id&.to_s,
96
+ metadata: context.to_json,
97
+ synced_at: Time.current
98
+ )
99
+
100
+ Result.new(success: true, data: synced_issue)
101
+ end
102
+
103
+ # Setup auto-tracking infrastructure (project + label)
104
+ def setup!
105
+ {
106
+ project_id: ensure_auto_tracked_project,
107
+ label_id: ensure_auto_tracked_label
108
+ }
109
+ end
110
+
111
+ private
112
+
113
+ def generate_fingerprint(exception, source = nil)
114
+ location = exception.backtrace&.first || 'unknown'
115
+ # Strip Rails root if available
116
+ location = location.gsub(Rails.root.to_s, '') if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
117
+ base = "#{exception.class}:#{location}"
118
+ source ? "#{base}:#{source.class}:#{source.id}" : base
119
+ end
120
+
121
+ def generate_custom_fingerprint(title, source = nil)
122
+ base = Digest::SHA256.hexdigest(title)[0..16]
123
+ source ? "custom:#{base}:#{source.class}:#{source.id}" : "custom:#{base}"
124
+ end
125
+
126
+ def resolve_label_ids(labels)
127
+ labels.filter_map { |label| CachedMetadata.resolve_label_id(label) }
128
+ end
129
+
130
+ # Ensure the "Auto-Tracked Errors" project exists
131
+ def ensure_auto_tracked_project
132
+ # Check cache first
133
+ cached = CachedMetadata.projects.find_by(key: AUTO_TRACKED_PROJECT_KEY)
134
+ return cached.linear_id if cached
135
+
136
+ # Check if project exists in Linear
137
+ result = LinearApi.client.list_projects
138
+ if result.success?
139
+ existing = result.data.find { |p| p.name == AUTO_TRACKED_PROJECT_NAME }
140
+ if existing
141
+ cache_project(existing)
142
+ return existing.id
143
+ end
144
+ end
145
+
146
+ # Create new project
147
+ create_result = LinearApi.client.create_project(
148
+ name: AUTO_TRACKED_PROJECT_NAME,
149
+ description: 'Automatically tracked errors and issues from the application. Do not manually add issues here.',
150
+ color: '#6b7280'
151
+ )
152
+
153
+ return nil unless create_result.success?
154
+
155
+ cache_project(create_result.data)
156
+ create_result.data.id
157
+ end
158
+
159
+ # Ensure the "auto:tracked" label exists
160
+ def ensure_auto_tracked_label
161
+ # Check cache first
162
+ cached = CachedMetadata.labels.find_by(key: AUTO_TRACKED_LABEL_NAME)
163
+ return cached.linear_id if cached
164
+
165
+ # Check if label exists in Linear
166
+ result = LinearApi.client.list_labels
167
+ if result.success?
168
+ existing = result.data.find { |l| l.name == AUTO_TRACKED_LABEL_NAME }
169
+ if existing
170
+ cache_label(existing)
171
+ return existing.id
172
+ end
173
+ end
174
+
175
+ # Create new label
176
+ create_result = LinearApi.client.create_label(
177
+ name: AUTO_TRACKED_LABEL_NAME,
178
+ color: AUTO_TRACKED_LABEL_COLOR,
179
+ description: 'Automatically tracked by LinearApi::IssueTracker'
180
+ )
181
+
182
+ return nil unless create_result.success?
183
+
184
+ cache_label(create_result.data)
185
+ create_result.data.id
186
+ end
187
+
188
+ def cache_project(project)
189
+ CachedMetadata.upsert_from_api(
190
+ type: 'project',
191
+ linear_id: project.id,
192
+ name: project.name,
193
+ key: AUTO_TRACKED_PROJECT_KEY,
194
+ raw_data: project.to_h
195
+ )
196
+ end
197
+
198
+ def cache_label(label)
199
+ CachedMetadata.upsert_from_api(
200
+ type: 'label',
201
+ linear_id: label.id,
202
+ name: label.name,
203
+ key: AUTO_TRACKED_LABEL_NAME,
204
+ category: 'auto',
205
+ color: label.color,
206
+ raw_data: label.to_h
207
+ )
208
+ end
209
+
210
+ def determine_priority(exception, context)
211
+ # Priority: 1=Urgent, 2=High, 3=Medium, 4=Low
212
+ return 1 if context[:critical] || context[:urgent]
213
+ return 2 if exception.is_a?(ActiveRecord::StatementInvalid)
214
+ return 2 if context[:high_priority]
215
+
216
+ 3 # Default to medium
217
+ end
218
+
219
+ def build_title(exception)
220
+ message = exception.message.to_s.truncate(80)
221
+ "[Auto] #{exception.class.name.demodulize}: #{message}"
222
+ end
223
+
224
+ def build_description(exception, context, source)
225
+ <<~MARKDOWN
226
+ ## Error Details
227
+
228
+ **Exception:** `#{exception.class}`
229
+ **Message:** #{exception.message}
230
+ **Source:** #{source_description(source)}
231
+
232
+ ## Context
233
+
234
+ ```json
235
+ #{JSON.pretty_generate(sanitize_context(context))}
236
+ ```
237
+
238
+ ## Backtrace (first 20 lines)
239
+
240
+ ```
241
+ #{exception.backtrace&.first(20)&.join("\n")}
242
+ ```
243
+
244
+ ---
245
+ *Auto-created by LinearApi::IssueTracker at #{Time.current.iso8601}*
246
+ MARKDOWN
247
+ end
248
+
249
+ def source_description(source)
250
+ return 'N/A' unless source
251
+
252
+ "#{source.class.name} ##{source.id}"
253
+ end
254
+
255
+ def sanitize_context(context)
256
+ context.transform_values do |value|
257
+ case value
258
+ when Time, DateTime then value.iso8601
259
+ when Date then value.to_s
260
+ when ActiveRecord::Base then "#{value.class.name}##{value.id}"
261
+ else value
262
+ end
263
+ end
264
+ end
265
+
266
+ def add_occurrence_comment(synced_issue, exception, context)
267
+ comment_body = <<~MARKDOWN
268
+ ## New Occurrence
269
+
270
+ **Time:** #{Time.current.iso8601}
271
+ **Message:** #{exception.message}
272
+
273
+ ### Context
274
+
275
+ ```json
276
+ #{JSON.pretty_generate(sanitize_context(context))}
277
+ ```
278
+ MARKDOWN
279
+
280
+ LinearApi.client.add_comment(
281
+ issue_id: synced_issue.linear_id,
282
+ body: comment_body
283
+ )
284
+
285
+ synced_issue.update!(synced_at: Time.current)
286
+
287
+ Result.new(success: true, data: synced_issue)
288
+ end
289
+ end
290
+ end
291
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ # Represents a Linear label
5
+ class Label
6
+ LIST_QUERY = <<~GRAPHQL
7
+ query ListLabels($teamId: String!) {
8
+ team(id: $teamId) {
9
+ labels {
10
+ nodes {
11
+ id
12
+ name
13
+ color
14
+ description
15
+ }
16
+ }
17
+ }
18
+ }
19
+ GRAPHQL
20
+
21
+ attr_reader :id, :name, :color, :description, :raw
22
+
23
+ def initialize(data)
24
+ @raw = data
25
+ @id = data['id']
26
+ @name = data['name']
27
+ @color = data['color']
28
+ @description = data['description']
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ id: id,
34
+ name: name,
35
+ color: color,
36
+ description: description
37
+ }
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ # Represents a Linear project (epic)
5
+ class Project
6
+ LIST_QUERY = <<~GRAPHQL
7
+ query ListProjects($teamId: String!) {
8
+ team(id: $teamId) {
9
+ projects {
10
+ nodes {
11
+ id
12
+ name
13
+ description
14
+ state
15
+ progress
16
+ }
17
+ }
18
+ }
19
+ }
20
+ GRAPHQL
21
+
22
+ attr_reader :id, :name, :description, :state, :progress, :raw
23
+
24
+ def initialize(data)
25
+ @raw = data
26
+ @id = data['id']
27
+ @name = data['name']
28
+ @description = data['description']
29
+ @state = data['state']
30
+ @progress = data['progress']
31
+ end
32
+
33
+ def to_h
34
+ {
35
+ id: id,
36
+ name: name,
37
+ description: description,
38
+ state: state,
39
+ progress: progress
40
+ }
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ # Result object for API operations
5
+ class Result
6
+ attr_reader :data, :error, :raw_response
7
+
8
+ def initialize(success:, data: nil, error: nil, raw_response: nil)
9
+ @success = success
10
+ @data = data
11
+ @error = error
12
+ @raw_response = raw_response
13
+ end
14
+
15
+ def success?
16
+ @success
17
+ end
18
+
19
+ def failure?
20
+ !@success
21
+ end
22
+
23
+ # Allow accessing data directly on result
24
+ def method_missing(method, *args, &block)
25
+ if data.respond_to?(method)
26
+ data.public_send(method, *args, &block)
27
+ else
28
+ super
29
+ end
30
+ end
31
+
32
+ def respond_to_missing?(method, include_private = false)
33
+ data.respond_to?(method, include_private) || super
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ # Represents a Linear team
5
+ class Team
6
+ GET_QUERY = <<~GRAPHQL
7
+ query GetTeam($teamId: String!) {
8
+ team(id: $teamId) {
9
+ id
10
+ name
11
+ key
12
+ description
13
+ }
14
+ }
15
+ GRAPHQL
16
+
17
+ STATES_QUERY = <<~GRAPHQL
18
+ query GetStates($teamId: String!) {
19
+ team(id: $teamId) {
20
+ states {
21
+ nodes {
22
+ id
23
+ name
24
+ type
25
+ position
26
+ }
27
+ }
28
+ }
29
+ }
30
+ GRAPHQL
31
+
32
+ attr_reader :id, :name, :key, :description, :raw
33
+
34
+ def initialize(data)
35
+ @raw = data
36
+ @id = data['id']
37
+ @name = data['name']
38
+ @key = data['key']
39
+ @description = data['description']
40
+ end
41
+
42
+ def to_h
43
+ {
44
+ id: id,
45
+ name: name,
46
+ key: key,
47
+ description: description
48
+ }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ VERSION = '0.3.0'
5
+ end
data/lib/linear_api.rb ADDED
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'linear_api/version'
4
+ require_relative 'linear_api/client'
5
+ require_relative 'linear_api/result'
6
+ require_relative 'linear_api/issue'
7
+ require_relative 'linear_api/project'
8
+ require_relative 'linear_api/label'
9
+ require_relative 'linear_api/team'
10
+
11
+ # Load Rails engine and services if Rails is present
12
+ if defined?(Rails::Engine)
13
+ require_relative 'linear_api/engine'
14
+ require_relative 'linear_api/cache_sync'
15
+ require_relative 'linear_api/issue_tracker'
16
+ end
17
+
18
+ module LinearApi
19
+ class Error < StandardError; end
20
+ class AuthenticationError < Error; end
21
+ class NotFoundError < Error; end
22
+ class RateLimitError < Error; end
23
+
24
+ class << self
25
+ attr_accessor :api_key, :team_id
26
+
27
+ def configure
28
+ yield self
29
+ end
30
+
31
+ def client
32
+ raise AuthenticationError, 'API key not configured' unless api_key
33
+
34
+ @client ||= Client.new(api_key: api_key, team_id: team_id)
35
+ end
36
+
37
+ def reset_client!
38
+ @client = nil
39
+ end
40
+ end
41
+ end