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,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LinearApi
4
+ class CacheSync
5
+ class << self
6
+ def sync_all
7
+ new.sync_all
8
+ end
9
+
10
+ def import_from_json(json_path)
11
+ new.import_from_json(json_path)
12
+ end
13
+ end
14
+
15
+ def sync_all
16
+ {
17
+ labels: sync_labels,
18
+ projects: sync_projects,
19
+ states: sync_states,
20
+ users: sync_users
21
+ }
22
+ end
23
+
24
+ def sync_labels
25
+ result = LinearApi.client.list_labels
26
+ return { success: false, error: result.error } unless result.success?
27
+
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
+ end
45
+
46
+ def sync_projects
47
+ result = LinearApi.client.list_projects
48
+ return { success: false, error: result.error } unless result.success?
49
+
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 }
65
+ end
66
+
67
+ def sync_states
68
+ result = LinearApi.client.list_states
69
+ return { success: false, error: result.error } unless result.success?
70
+
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 }
87
+ end
88
+
89
+ def sync_users
90
+ result = LinearApi.client.list_users
91
+ return { success: false, error: result.error } unless result.success?
92
+
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 }
107
+ end
108
+
109
+ # Import from linear.json file
110
+ def import_from_json(json_path)
111
+ data = JSON.parse(File.read(json_path))
112
+ counts = { labels: 0, projects: 0, states: 0, users: 0 }
113
+
114
+ # Import labels
115
+ import_labels_from_json(data['labels'], counts)
116
+
117
+ # Import projects
118
+ data['projects']&.each do |key, project|
119
+ CachedMetadata.upsert_from_api(
120
+ type: 'project',
121
+ linear_id: project['id'],
122
+ name: project['name'],
123
+ key: key,
124
+ state_type: project['state'],
125
+ raw_data: project
126
+ )
127
+ counts[:projects] += 1
128
+ end
129
+
130
+ # Import workflow states
131
+ data['workflow_states']&.each do |key, state|
132
+ CachedMetadata.upsert_from_api(
133
+ type: 'state',
134
+ linear_id: state['id'],
135
+ name: state['name'],
136
+ key: key,
137
+ state_type: state['type'],
138
+ raw_data: state
139
+ )
140
+ counts[:states] += 1
141
+ end
142
+
143
+ # Import users
144
+ data['users']&.each do |key, user|
145
+ CachedMetadata.upsert_from_api(
146
+ type: 'user',
147
+ linear_id: user['id'],
148
+ name: user['name'],
149
+ key: key,
150
+ raw_data: user
151
+ )
152
+ counts[:users] += 1
153
+ end
154
+
155
+ counts
156
+ end
157
+
158
+ private
159
+
160
+ def extract_category(label_name)
161
+ return nil unless label_name.include?(':')
162
+
163
+ label_name.split(':').first
164
+ end
165
+
166
+ def import_labels_from_json(labels_data, counts)
167
+ return unless labels_data
168
+
169
+ labels_data.each do |category, labels|
170
+ labels.each do |_key, label|
171
+ CachedMetadata.upsert_from_api(
172
+ type: 'label',
173
+ linear_id: label['id'],
174
+ name: label['name'],
175
+ key: label['name'],
176
+ color: label['color'],
177
+ category: category,
178
+ raw_data: label
179
+ )
180
+ counts[:labels] += 1
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,519 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+
6
+ module LinearApi
7
+ # GraphQL client for Linear API
8
+ class Client
9
+ API_ENDPOINT = 'https://api.linear.app/graphql'
10
+
11
+ attr_reader :api_key, :team_id
12
+
13
+ def initialize(api_key:, team_id: nil)
14
+ @api_key = api_key
15
+ @team_id = team_id
16
+ end
17
+
18
+ # Execute a GraphQL query
19
+ #
20
+ # @param query [String] GraphQL query string
21
+ # @param variables [Hash] Query variables
22
+ # @return [Result] Result object with data or error
23
+ def query(query, variables: {})
24
+ response = connection.post do |req|
25
+ req.body = { query: query, variables: variables }.to_json
26
+ end
27
+
28
+ handle_response(response)
29
+ rescue Faraday::Error => e
30
+ Result.new(success: false, error: "Network error: #{e.message}")
31
+ end
32
+
33
+ # Create a new issue
34
+ #
35
+ # @param title [String] Issue title
36
+ # @param description [String] Issue description (markdown)
37
+ # @param options [Hash] Additional options (priority, label_ids, project_id, etc.)
38
+ # @return [Result] Result with created issue
39
+ def create_issue(title:, description: nil, **options)
40
+ input = build_issue_input(title: title, description: description, **options)
41
+
42
+ result = query(Issue::CREATE_MUTATION, variables: { input: input })
43
+ return result if result.failure?
44
+
45
+ issue_data = result.data.dig('issueCreate', 'issue')
46
+ Result.new(
47
+ success: result.data.dig('issueCreate', 'success'),
48
+ data: Issue.new(issue_data),
49
+ raw_response: result.raw_response
50
+ )
51
+ end
52
+
53
+ # Update an existing issue
54
+ #
55
+ # @param id [String] Issue ID
56
+ # @param options [Hash] Fields to update
57
+ # @return [Result] Result with updated issue
58
+ def update_issue(id:, **options)
59
+ input = options.transform_keys { |k| camelize(k) }
60
+
61
+ result = query(Issue::UPDATE_MUTATION, variables: { id: id, input: input })
62
+ return result if result.failure?
63
+
64
+ issue_data = result.data.dig('issueUpdate', 'issue')
65
+ Result.new(
66
+ success: result.data.dig('issueUpdate', 'success'),
67
+ data: Issue.new(issue_data),
68
+ raw_response: result.raw_response
69
+ )
70
+ end
71
+
72
+ # Get issue by identifier (e.g., "TOS-123")
73
+ #
74
+ # @param identifier [String] Issue identifier
75
+ # @return [Result] Result with issue
76
+ def get_issue(identifier)
77
+ result = query(Issue::SEARCH_QUERY, variables: { term: identifier })
78
+ return result if result.failure?
79
+
80
+ issues = result.data.dig('searchIssues', 'nodes') || []
81
+ if issues.empty?
82
+ Result.new(success: false, error: "Issue not found: #{identifier}")
83
+ else
84
+ Result.new(success: true, data: Issue.new(issues.first), raw_response: result.raw_response)
85
+ end
86
+ end
87
+
88
+ # List issues for the team
89
+ #
90
+ # @param filter [Hash] Filter options
91
+ # @param limit [Integer] Max issues to return
92
+ # @return [Result] Result with array of issues
93
+ def list_issues(filter: nil, limit: 50)
94
+ variables = { teamId: team_id, first: limit }
95
+ variables[:filter] = filter if filter
96
+
97
+ result = query(Issue::LIST_QUERY, variables: variables)
98
+ return result if result.failure?
99
+
100
+ issues = (result.data.dig('team', 'issues', 'nodes') || []).map { |i| Issue.new(i) }
101
+ Result.new(success: true, data: issues, raw_response: result.raw_response)
102
+ end
103
+
104
+ # Add a comment to an issue
105
+ #
106
+ # @param issue_id [String] Issue ID
107
+ # @param body [String] Comment body (markdown)
108
+ # @return [Result] Result with comment
109
+ def add_comment(issue_id:, body:)
110
+ result = query(Issue::ADD_COMMENT_MUTATION, variables: { issueId: issue_id, body: body })
111
+ return result if result.failure?
112
+
113
+ Result.new(
114
+ success: result.data.dig('commentCreate', 'success'),
115
+ data: result.data.dig('commentCreate', 'comment'),
116
+ raw_response: result.raw_response
117
+ )
118
+ end
119
+
120
+ # Get team info
121
+ #
122
+ # @return [Result] Result with team data
123
+ def get_team
124
+ result = query(Team::GET_QUERY, variables: { teamId: team_id })
125
+ return result if result.failure?
126
+
127
+ team_data = result.data['team']
128
+ Result.new(success: true, data: Team.new(team_data), raw_response: result.raw_response)
129
+ end
130
+
131
+ # List projects for the team
132
+ #
133
+ # @return [Result] Result with array of projects
134
+ def list_projects
135
+ result = query(Project::LIST_QUERY, variables: { teamId: team_id })
136
+ return result if result.failure?
137
+
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)
140
+ end
141
+
142
+ # Create a new project
143
+ #
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 })
169
+ return result if result.failure?
170
+
171
+ project_data = result.data.dig('projectCreate', 'project')
172
+ Result.new(
173
+ success: result.data.dig('projectCreate', 'success'),
174
+ data: Project.new(project_data),
175
+ raw_response: result.raw_response
176
+ )
177
+ end
178
+
179
+ # List labels for the team
180
+ #
181
+ # @return [Result] Result with array of labels
182
+ def list_labels
183
+ result = query(Label::LIST_QUERY, variables: { teamId: team_id })
184
+ return result if result.failure?
185
+
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)
188
+ end
189
+
190
+ # Get workflow states for the team
191
+ #
192
+ # @return [Result] Result with array of states
193
+ def list_states
194
+ result = query(Team::STATES_QUERY, variables: { teamId: team_id })
195
+ return result if result.failure?
196
+
197
+ states = result.data.dig('team', 'states', 'nodes') || []
198
+ Result.new(success: true, data: states, raw_response: result.raw_response)
199
+ end
200
+
201
+ # ==========================================================================
202
+ # Convenience Methods for Common Operations
203
+ # ==========================================================================
204
+
205
+ # Move an issue to a different state
206
+ #
207
+ # @param id [String] Issue ID
208
+ # @param state_id [String] Target state ID
209
+ # @return [Result] Result with updated issue
210
+ def move_to_state(id:, state_id:)
211
+ update_issue(id: id, state_id: state_id)
212
+ end
213
+
214
+ # Add labels to an issue (appends to existing)
215
+ #
216
+ # @param id [String] Issue ID
217
+ # @param label_ids [Array<String>] Label IDs to add
218
+ # @return [Result] Result with updated issue
219
+ def add_labels(id:, label_ids:)
220
+ # First get current labels
221
+ issue_result = get_issue_by_id(id)
222
+ return issue_result if issue_result.failure?
223
+
224
+ current_ids = issue_result.data.labels.map { |l| l['id'] }
225
+ new_ids = (current_ids + label_ids).uniq
226
+
227
+ update_issue(id: id, label_ids: new_ids)
228
+ end
229
+
230
+ # Remove labels from an issue
231
+ #
232
+ # @param id [String] Issue ID
233
+ # @param label_ids [Array<String>] Label IDs to remove
234
+ # @return [Result] Result with updated issue
235
+ def remove_labels(id:, label_ids:)
236
+ issue_result = get_issue_by_id(id)
237
+ return issue_result if issue_result.failure?
238
+
239
+ current_ids = issue_result.data.labels.map { |l| l['id'] }
240
+ new_ids = current_ids - label_ids
241
+
242
+ update_issue(id: id, label_ids: new_ids)
243
+ end
244
+
245
+ # Assign issue to a user
246
+ #
247
+ # @param id [String] Issue ID
248
+ # @param assignee_id [String] User ID (nil to unassign)
249
+ # @return [Result] Result with updated issue
250
+ def assign(id:, assignee_id:)
251
+ update_issue(id: id, assignee_id: assignee_id)
252
+ end
253
+
254
+ # Link a GitHub PR to an issue
255
+ #
256
+ # @param issue_id [String] Issue ID
257
+ # @param pr_url [String] GitHub PR URL
258
+ # @return [Result] Result with attachment
259
+ 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 })
273
+ return result if result.failure?
274
+
275
+ Result.new(
276
+ success: result.data.dig('attachmentLinkURL', 'success'),
277
+ data: result.data.dig('attachmentLinkURL', 'attachment'),
278
+ raw_response: result.raw_response
279
+ )
280
+ end
281
+
282
+ # List team members for assignment
283
+ #
284
+ # @return [Result] Result with array of users
285
+ 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 })
303
+ return result if result.failure?
304
+
305
+ users = result.data.dig('team', 'members', 'nodes') || []
306
+ Result.new(success: true, data: users, raw_response: result.raw_response)
307
+ end
308
+
309
+ # Archive an issue (soft delete)
310
+ #
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 })
323
+ return result if result.failure?
324
+
325
+ Result.new(
326
+ success: result.data.dig('issueArchive', 'success'),
327
+ data: { archived: true },
328
+ raw_response: result.raw_response
329
+ )
330
+ end
331
+
332
+ # Unarchive an issue
333
+ #
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 })
346
+ return result if result.failure?
347
+
348
+ Result.new(
349
+ success: result.data.dig('issueUnarchive', 'success'),
350
+ data: { archived: false },
351
+ raw_response: result.raw_response
352
+ )
353
+ end
354
+
355
+ # Create a new label
356
+ #
357
+ # @param name [String] Label name (e.g., "type:bug", "area:sync")
358
+ # @param color [String] Hex color (e.g., "#ff0000")
359
+ # @param description [String] Optional description
360
+ # @return [Result] Result with created label
361
+ 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
+ input = { name: name, teamId: team_id }
377
+ input[:color] = color if color
378
+ input[:description] = description if description
379
+
380
+ result = query(create_label_mutation, variables: { input: input })
381
+ return result if result.failure?
382
+
383
+ label_data = result.data.dig('issueLabelCreate', 'issueLabel')
384
+ Result.new(
385
+ success: result.data.dig('issueLabelCreate', 'success'),
386
+ data: Label.new(label_data),
387
+ raw_response: result.raw_response
388
+ )
389
+ end
390
+
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
+ private
481
+
482
+ def connection
483
+ @connection ||= Faraday.new(url: API_ENDPOINT) do |f|
484
+ f.headers['Content-Type'] = 'application/json'
485
+ f.headers['Authorization'] = api_key
486
+ f.adapter Faraday.default_adapter
487
+ end
488
+ end
489
+
490
+ def handle_response(response)
491
+ data = JSON.parse(response.body)
492
+
493
+ if data['errors']
494
+ error_message = data['errors'].map { |e| e['message'] }.join(', ')
495
+ return Result.new(success: false, error: error_message, raw_response: data)
496
+ end
497
+
498
+ Result.new(success: true, data: data['data'], raw_response: data)
499
+ rescue JSON::ParserError => e
500
+ Result.new(success: false, error: "Invalid JSON: #{e.message}")
501
+ end
502
+
503
+ def build_issue_input(title:, description: nil, **options)
504
+ input = { title: title, teamId: team_id }
505
+ input[:description] = description if description
506
+
507
+ # Map Ruby snake_case to GraphQL camelCase
508
+ options.each do |key, value|
509
+ input[camelize(key)] = value
510
+ end
511
+
512
+ input
513
+ end
514
+
515
+ def camelize(key)
516
+ key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }.to_sym
517
+ end
518
+ end
519
+ end