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 +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +113 -11
- data/app/models/linear_api/synced_issue.rb +38 -4
- data/lib/linear_api/cache_sync.rb +101 -65
- data/lib/linear_api/client.rb +235 -228
- data/lib/linear_api/engine.rb +1 -0
- data/lib/linear_api/issue.rb +219 -11
- data/lib/linear_api/issue_tracker.rb +51 -17
- data/lib/linear_api/label.rb +14 -0
- data/lib/linear_api/project.rb +24 -2
- data/lib/linear_api/result.rb +22 -10
- data/lib/linear_api/team.rb +60 -0
- data/lib/linear_api/version.rb +1 -1
- data/lib/linear_api.rb +42 -5
- data/lib/tasks/linear_api.rake +29 -12
- metadata +22 -2
data/lib/linear_api/client.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'faraday'
|
|
4
|
+
require 'faraday/retry'
|
|
4
5
|
require 'json'
|
|
5
6
|
|
|
6
7
|
module LinearApi
|
|
@@ -8,11 +9,22 @@ module LinearApi
|
|
|
8
9
|
class Client
|
|
9
10
|
API_ENDPOINT = 'https://api.linear.app/graphql'
|
|
10
11
|
|
|
12
|
+
# Default timeouts (seconds)
|
|
13
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
14
|
+
DEFAULT_READ_TIMEOUT = 15
|
|
15
|
+
|
|
16
|
+
# Retry configuration
|
|
17
|
+
DEFAULT_MAX_RETRIES = 3
|
|
18
|
+
DEFAULT_RETRY_INTERVAL = 0.5
|
|
19
|
+
|
|
11
20
|
attr_reader :api_key, :team_id
|
|
12
21
|
|
|
13
|
-
def initialize(api_key:, team_id: nil)
|
|
22
|
+
def initialize(api_key:, team_id: nil, open_timeout: nil, read_timeout: nil, max_retries: nil)
|
|
14
23
|
@api_key = api_key
|
|
15
24
|
@team_id = team_id
|
|
25
|
+
@open_timeout = open_timeout || DEFAULT_OPEN_TIMEOUT
|
|
26
|
+
@read_timeout = read_timeout || DEFAULT_READ_TIMEOUT
|
|
27
|
+
@max_retries = max_retries || DEFAULT_MAX_RETRIES
|
|
16
28
|
end
|
|
17
29
|
|
|
18
30
|
# Execute a GraphQL query
|
|
@@ -20,16 +32,28 @@ module LinearApi
|
|
|
20
32
|
# @param query [String] GraphQL query string
|
|
21
33
|
# @param variables [Hash] Query variables
|
|
22
34
|
# @return [Result] Result object with data or error
|
|
23
|
-
def query(
|
|
35
|
+
def query(query_str, variables: {})
|
|
36
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
37
|
+
|
|
38
|
+
LinearApi.logger.debug { "LinearApi: executing query #{query_str.lines.first&.strip}" }
|
|
39
|
+
|
|
24
40
|
response = connection.post do |req|
|
|
25
|
-
req.body = { query:
|
|
41
|
+
req.body = { query: query_str, variables: variables }.to_json
|
|
26
42
|
end
|
|
27
43
|
|
|
44
|
+
elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(1)
|
|
45
|
+
LinearApi.logger.debug { "LinearApi: response #{response.status} in #{elapsed}ms" }
|
|
46
|
+
|
|
28
47
|
handle_response(response)
|
|
29
48
|
rescue Faraday::Error => e
|
|
49
|
+
LinearApi.logger.error { "LinearApi: network error: #{e.class} - #{e.message}" }
|
|
30
50
|
Result.new(success: false, error: "Network error: #{e.message}")
|
|
31
51
|
end
|
|
32
52
|
|
|
53
|
+
# ==========================================================================
|
|
54
|
+
# Issue Operations
|
|
55
|
+
# ==========================================================================
|
|
56
|
+
|
|
33
57
|
# Create a new issue
|
|
34
58
|
#
|
|
35
59
|
# @param title [String] Issue title
|
|
@@ -69,15 +93,15 @@ module LinearApi
|
|
|
69
93
|
)
|
|
70
94
|
end
|
|
71
95
|
|
|
72
|
-
# Get issue by identifier (e.g., "TOS-123")
|
|
96
|
+
# Get issue by identifier (e.g., "TOS-123") using direct filter query
|
|
73
97
|
#
|
|
74
98
|
# @param identifier [String] Issue identifier
|
|
75
99
|
# @return [Result] Result with issue
|
|
76
100
|
def get_issue(identifier)
|
|
77
|
-
result = query(Issue::
|
|
101
|
+
result = query(Issue::GET_BY_IDENTIFIER_QUERY, variables: { filter: { identifier: { eq: identifier } } })
|
|
78
102
|
return result if result.failure?
|
|
79
103
|
|
|
80
|
-
issues = result.data.dig('
|
|
104
|
+
issues = result.data.dig('issues', 'nodes') || []
|
|
81
105
|
if issues.empty?
|
|
82
106
|
Result.new(success: false, error: "Issue not found: #{identifier}")
|
|
83
107
|
else
|
|
@@ -85,6 +109,19 @@ module LinearApi
|
|
|
85
109
|
end
|
|
86
110
|
end
|
|
87
111
|
|
|
112
|
+
# Search issues by text query (full-text search)
|
|
113
|
+
#
|
|
114
|
+
# @param term [String] Search term
|
|
115
|
+
# @param limit [Integer] Max results
|
|
116
|
+
# @return [Result] Result with array of issues
|
|
117
|
+
def search_issues(term:, limit: 10)
|
|
118
|
+
result = query(Issue::SEARCH_QUERY, variables: { term: term, first: limit })
|
|
119
|
+
return result if result.failure?
|
|
120
|
+
|
|
121
|
+
issues = (result.data.dig('searchIssues', 'nodes') || []).map { |i| Issue.new(i) }
|
|
122
|
+
Result.new(success: true, data: issues, raw_response: result.raw_response)
|
|
123
|
+
end
|
|
124
|
+
|
|
88
125
|
# List issues for the team
|
|
89
126
|
#
|
|
90
127
|
# @param filter [Hash] Filter options
|
|
@@ -101,6 +138,39 @@ module LinearApi
|
|
|
101
138
|
Result.new(success: true, data: issues, raw_response: result.raw_response)
|
|
102
139
|
end
|
|
103
140
|
|
|
141
|
+
# List all issues with automatic pagination
|
|
142
|
+
#
|
|
143
|
+
# @param filter [Hash] Filter options
|
|
144
|
+
# @param batch_size [Integer] Items per page
|
|
145
|
+
# @yield [Issue] Each issue as it's fetched (optional)
|
|
146
|
+
# @return [Result] Result with array of all issues
|
|
147
|
+
def list_all_issues(filter: nil, batch_size: 50, &block)
|
|
148
|
+
all_issues = []
|
|
149
|
+
cursor = nil
|
|
150
|
+
|
|
151
|
+
loop do
|
|
152
|
+
variables = { teamId: team_id, first: batch_size }
|
|
153
|
+
variables[:filter] = filter if filter
|
|
154
|
+
variables[:after] = cursor if cursor
|
|
155
|
+
|
|
156
|
+
result = query(Issue::LIST_PAGINATED_QUERY, variables: variables)
|
|
157
|
+
return result if result.failure?
|
|
158
|
+
|
|
159
|
+
nodes = (result.data.dig('team', 'issues', 'nodes') || []).map { |i| Issue.new(i) }
|
|
160
|
+
nodes.each do |issue|
|
|
161
|
+
block&.call(issue)
|
|
162
|
+
all_issues << issue
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
page_info = result.data.dig('team', 'issues', 'pageInfo')
|
|
166
|
+
break unless page_info&.dig('hasNextPage')
|
|
167
|
+
|
|
168
|
+
cursor = page_info['endCursor']
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
Result.new(success: true, data: all_issues)
|
|
172
|
+
end
|
|
173
|
+
|
|
104
174
|
# Add a comment to an issue
|
|
105
175
|
#
|
|
106
176
|
# @param issue_id [String] Issue ID
|
|
@@ -117,89 +187,88 @@ module LinearApi
|
|
|
117
187
|
)
|
|
118
188
|
end
|
|
119
189
|
|
|
120
|
-
# Get
|
|
190
|
+
# Get issue by UUID (not identifier)
|
|
121
191
|
#
|
|
122
|
-
# @
|
|
123
|
-
|
|
124
|
-
|
|
192
|
+
# @param id [String] Issue UUID
|
|
193
|
+
# @return [Result] Result with issue
|
|
194
|
+
def get_issue_by_id(id)
|
|
195
|
+
result = query(Issue::GET_BY_ID_QUERY, variables: { id: id })
|
|
125
196
|
return result if result.failure?
|
|
126
197
|
|
|
127
|
-
|
|
128
|
-
|
|
198
|
+
issue_data = result.data['issue']
|
|
199
|
+
if issue_data.nil?
|
|
200
|
+
Result.new(success: false, error: "Issue not found: #{id}")
|
|
201
|
+
else
|
|
202
|
+
Result.new(success: true, data: Issue.new(issue_data), raw_response: result.raw_response)
|
|
203
|
+
end
|
|
129
204
|
end
|
|
130
205
|
|
|
131
|
-
#
|
|
206
|
+
# Batch fetch issues by IDs (avoids N+1 API calls)
|
|
132
207
|
#
|
|
133
|
-
# @
|
|
134
|
-
|
|
135
|
-
|
|
208
|
+
# @param ids [Array<String>] Issue UUIDs
|
|
209
|
+
# @return [Result] Result with array of issues
|
|
210
|
+
def batch_get_issues(ids:)
|
|
211
|
+
result = query(Issue::BATCH_GET_QUERY, variables: { filter: { id: { in: ids } } })
|
|
136
212
|
return result if result.failure?
|
|
137
213
|
|
|
138
|
-
|
|
139
|
-
Result.new(success: true, data:
|
|
214
|
+
issues = (result.data.dig('issues', 'nodes') || []).map { |i| Issue.new(i) }
|
|
215
|
+
Result.new(success: true, data: issues, raw_response: result.raw_response)
|
|
140
216
|
end
|
|
141
217
|
|
|
142
|
-
#
|
|
218
|
+
# Archive an issue (soft delete)
|
|
143
219
|
#
|
|
144
|
-
# @param
|
|
145
|
-
# @
|
|
146
|
-
|
|
147
|
-
|
|
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 })
|
|
220
|
+
# @param id [String] Issue ID
|
|
221
|
+
# @return [Result] Result with success status
|
|
222
|
+
def archive_issue(id:)
|
|
223
|
+
result = query(Issue::ARCHIVE_MUTATION, variables: { id: id })
|
|
169
224
|
return result if result.failure?
|
|
170
225
|
|
|
171
|
-
project_data = result.data.dig('projectCreate', 'project')
|
|
172
226
|
Result.new(
|
|
173
|
-
success: result.data.dig('
|
|
174
|
-
data:
|
|
227
|
+
success: result.data.dig('issueArchive', 'success'),
|
|
228
|
+
data: { archived: true },
|
|
175
229
|
raw_response: result.raw_response
|
|
176
230
|
)
|
|
177
231
|
end
|
|
178
232
|
|
|
179
|
-
#
|
|
233
|
+
# Unarchive an issue
|
|
180
234
|
#
|
|
181
|
-
# @
|
|
182
|
-
|
|
183
|
-
|
|
235
|
+
# @param id [String] Issue ID
|
|
236
|
+
# @return [Result] Result with success status
|
|
237
|
+
def unarchive_issue(id:)
|
|
238
|
+
result = query(Issue::UNARCHIVE_MUTATION, variables: { id: id })
|
|
184
239
|
return result if result.failure?
|
|
185
240
|
|
|
186
|
-
|
|
187
|
-
|
|
241
|
+
Result.new(
|
|
242
|
+
success: result.data.dig('issueUnarchive', 'success'),
|
|
243
|
+
data: { archived: false },
|
|
244
|
+
raw_response: result.raw_response
|
|
245
|
+
)
|
|
188
246
|
end
|
|
189
247
|
|
|
190
|
-
#
|
|
248
|
+
# Create a sub-issue (child issue)
|
|
191
249
|
#
|
|
192
|
-
# @
|
|
193
|
-
|
|
194
|
-
|
|
250
|
+
# @param parent_id [String] Parent issue ID
|
|
251
|
+
# @param title [String] Sub-issue title
|
|
252
|
+
# @param options [Hash] Additional options
|
|
253
|
+
# @return [Result] Result with created sub-issue
|
|
254
|
+
def create_sub_issue(parent_id:, title:, **options)
|
|
255
|
+
create_issue(title: title, parent_id: parent_id, **options)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Get sub-issues for an issue
|
|
259
|
+
#
|
|
260
|
+
# @param parent_id [String] Parent issue ID
|
|
261
|
+
# @return [Result] Result with array of sub-issues
|
|
262
|
+
def list_sub_issues(parent_id:)
|
|
263
|
+
result = query(Issue::SUB_ISSUES_QUERY, variables: { id: parent_id })
|
|
195
264
|
return result if result.failure?
|
|
196
265
|
|
|
197
|
-
|
|
198
|
-
Result.new(success: true, data:
|
|
266
|
+
children = (result.data.dig('issue', 'children', 'nodes') || []).map { |i| Issue.new(i) }
|
|
267
|
+
Result.new(success: true, data: children, raw_response: result.raw_response)
|
|
199
268
|
end
|
|
200
269
|
|
|
201
270
|
# ==========================================================================
|
|
202
|
-
# Convenience Methods for Common Operations
|
|
271
|
+
# Convenience Methods for Common Issue Operations
|
|
203
272
|
# ==========================================================================
|
|
204
273
|
|
|
205
274
|
# Move an issue to a different state
|
|
@@ -217,7 +286,6 @@ module LinearApi
|
|
|
217
286
|
# @param label_ids [Array<String>] Label IDs to add
|
|
218
287
|
# @return [Result] Result with updated issue
|
|
219
288
|
def add_labels(id:, label_ids:)
|
|
220
|
-
# First get current labels
|
|
221
289
|
issue_result = get_issue_by_id(id)
|
|
222
290
|
return issue_result if issue_result.failure?
|
|
223
291
|
|
|
@@ -257,19 +325,7 @@ module LinearApi
|
|
|
257
325
|
# @param pr_url [String] GitHub PR URL
|
|
258
326
|
# @return [Result] Result with attachment
|
|
259
327
|
def link_pr(issue_id:, pr_url:)
|
|
260
|
-
|
|
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 })
|
|
328
|
+
result = query(Issue::LINK_PR_MUTATION, variables: { issueId: issue_id, url: pr_url })
|
|
273
329
|
return result if result.failure?
|
|
274
330
|
|
|
275
331
|
Result.new(
|
|
@@ -279,79 +335,112 @@ module LinearApi
|
|
|
279
335
|
)
|
|
280
336
|
end
|
|
281
337
|
|
|
338
|
+
# ==========================================================================
|
|
339
|
+
# Team Operations
|
|
340
|
+
# ==========================================================================
|
|
341
|
+
|
|
342
|
+
# Get team info
|
|
343
|
+
#
|
|
344
|
+
# @return [Result] Result with team data
|
|
345
|
+
def get_team
|
|
346
|
+
result = query(Team::GET_QUERY, variables: { teamId: team_id })
|
|
347
|
+
return result if result.failure?
|
|
348
|
+
|
|
349
|
+
team_data = result.data['team']
|
|
350
|
+
Result.new(success: true, data: Team.new(team_data), raw_response: result.raw_response)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Get workflow states for the team
|
|
354
|
+
#
|
|
355
|
+
# @return [Result] Result with array of states
|
|
356
|
+
def list_states
|
|
357
|
+
result = query(Team::STATES_QUERY, variables: { teamId: team_id })
|
|
358
|
+
return result if result.failure?
|
|
359
|
+
|
|
360
|
+
states = result.data.dig('team', 'states', 'nodes') || []
|
|
361
|
+
Result.new(success: true, data: states, raw_response: result.raw_response)
|
|
362
|
+
end
|
|
363
|
+
|
|
282
364
|
# List team members for assignment
|
|
283
365
|
#
|
|
284
366
|
# @return [Result] Result with array of users
|
|
285
367
|
def list_users
|
|
286
|
-
|
|
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 })
|
|
368
|
+
result = query(Team::USERS_QUERY, variables: { teamId: team_id })
|
|
303
369
|
return result if result.failure?
|
|
304
370
|
|
|
305
371
|
users = result.data.dig('team', 'members', 'nodes') || []
|
|
306
372
|
Result.new(success: true, data: users, raw_response: result.raw_response)
|
|
307
373
|
end
|
|
308
374
|
|
|
309
|
-
#
|
|
375
|
+
# Fetch all team metadata in a single API call (labels, projects, states, users)
|
|
310
376
|
#
|
|
311
|
-
# @
|
|
312
|
-
|
|
313
|
-
|
|
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 })
|
|
377
|
+
# @return [Result] Result with hash of { labels:, projects:, states:, users: }
|
|
378
|
+
def fetch_all_metadata
|
|
379
|
+
result = query(Team::ALL_METADATA_QUERY, variables: { teamId: team_id })
|
|
323
380
|
return result if result.failure?
|
|
324
381
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
382
|
+
team = result.data['team']
|
|
383
|
+
data = {
|
|
384
|
+
labels: (team.dig('labels', 'nodes') || []).map { |l| Label.new(l) },
|
|
385
|
+
projects: (team.dig('projects', 'nodes') || []).map { |p| Project.new(p) },
|
|
386
|
+
states: team.dig('states', 'nodes') || [],
|
|
387
|
+
users: team.dig('members', 'nodes') || []
|
|
388
|
+
}
|
|
389
|
+
Result.new(success: true, data: data, raw_response: result.raw_response)
|
|
330
390
|
end
|
|
331
391
|
|
|
332
|
-
#
|
|
392
|
+
# ==========================================================================
|
|
393
|
+
# Project Operations
|
|
394
|
+
# ==========================================================================
|
|
395
|
+
|
|
396
|
+
# List projects for the team
|
|
333
397
|
#
|
|
334
|
-
# @
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
398
|
+
# @return [Result] Result with array of projects
|
|
399
|
+
def list_projects
|
|
400
|
+
result = query(Project::LIST_QUERY, variables: { teamId: team_id })
|
|
401
|
+
return result if result.failure?
|
|
402
|
+
|
|
403
|
+
projects = (result.data.dig('team', 'projects', 'nodes') || []).map { |p| Project.new(p) }
|
|
404
|
+
Result.new(success: true, data: projects, raw_response: result.raw_response)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
# Create a new project
|
|
408
|
+
#
|
|
409
|
+
# @param name [String] Project name
|
|
410
|
+
# @param description [String] Optional project description
|
|
411
|
+
# @param color [String] Optional hex color (e.g., "#0052CC")
|
|
412
|
+
# @return [Result] Result with created project
|
|
413
|
+
def create_project(name:, description: nil, color: nil)
|
|
414
|
+
input = { name: name, teamIds: [team_id] }
|
|
415
|
+
input[:description] = description if description
|
|
416
|
+
input[:color] = color if color
|
|
417
|
+
|
|
418
|
+
result = query(Project::CREATE_MUTATION, variables: { input: input })
|
|
346
419
|
return result if result.failure?
|
|
347
420
|
|
|
421
|
+
project_data = result.data.dig('projectCreate', 'project')
|
|
348
422
|
Result.new(
|
|
349
|
-
success: result.data.dig('
|
|
350
|
-
data:
|
|
423
|
+
success: result.data.dig('projectCreate', 'success'),
|
|
424
|
+
data: Project.new(project_data),
|
|
351
425
|
raw_response: result.raw_response
|
|
352
426
|
)
|
|
353
427
|
end
|
|
354
428
|
|
|
429
|
+
# ==========================================================================
|
|
430
|
+
# Label Operations
|
|
431
|
+
# ==========================================================================
|
|
432
|
+
|
|
433
|
+
# List labels for the team
|
|
434
|
+
#
|
|
435
|
+
# @return [Result] Result with array of labels
|
|
436
|
+
def list_labels
|
|
437
|
+
result = query(Label::LIST_QUERY, variables: { teamId: team_id })
|
|
438
|
+
return result if result.failure?
|
|
439
|
+
|
|
440
|
+
labels = (result.data.dig('team', 'labels', 'nodes') || []).map { |l| Label.new(l) }
|
|
441
|
+
Result.new(success: true, data: labels, raw_response: result.raw_response)
|
|
442
|
+
end
|
|
443
|
+
|
|
355
444
|
# Create a new label
|
|
356
445
|
#
|
|
357
446
|
# @param name [String] Label name (e.g., "type:bug", "area:sync")
|
|
@@ -359,25 +448,11 @@ module LinearApi
|
|
|
359
448
|
# @param description [String] Optional description
|
|
360
449
|
# @return [Result] Result with created label
|
|
361
450
|
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
451
|
input = { name: name, teamId: team_id }
|
|
377
452
|
input[:color] = color if color
|
|
378
453
|
input[:description] = description if description
|
|
379
454
|
|
|
380
|
-
result = query(
|
|
455
|
+
result = query(Label::CREATE_MUTATION, variables: { input: input })
|
|
381
456
|
return result if result.failure?
|
|
382
457
|
|
|
383
458
|
label_data = result.data.dig('issueLabelCreate', 'issueLabel')
|
|
@@ -388,99 +463,22 @@ module LinearApi
|
|
|
388
463
|
)
|
|
389
464
|
end
|
|
390
465
|
|
|
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
466
|
private
|
|
481
467
|
|
|
482
468
|
def connection
|
|
483
469
|
@connection ||= Faraday.new(url: API_ENDPOINT) do |f|
|
|
470
|
+
f.options.timeout = @read_timeout
|
|
471
|
+
f.options.open_timeout = @open_timeout
|
|
472
|
+
f.request :retry, max: @max_retries,
|
|
473
|
+
interval: DEFAULT_RETRY_INTERVAL,
|
|
474
|
+
backoff_factor: 2,
|
|
475
|
+
exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed],
|
|
476
|
+
retry_statuses: [500, 502, 503],
|
|
477
|
+
retry_block: lambda { |env, _opts, retries, exc|
|
|
478
|
+
LinearApi.logger.warn do
|
|
479
|
+
"LinearApi: retry #{retries}/#{@max_retries} after #{exc&.class || "HTTP #{env&.status}"}"
|
|
480
|
+
end
|
|
481
|
+
}
|
|
484
482
|
f.headers['Content-Type'] = 'application/json'
|
|
485
483
|
f.headers['Authorization'] = api_key
|
|
486
484
|
f.adapter Faraday.default_adapter
|
|
@@ -488,15 +486,24 @@ module LinearApi
|
|
|
488
486
|
end
|
|
489
487
|
|
|
490
488
|
def handle_response(response)
|
|
489
|
+
# Check for rate limiting
|
|
490
|
+
if response.status == 429
|
|
491
|
+
retry_after = response.headers['retry-after']&.to_i
|
|
492
|
+
LinearApi.logger.warn { "LinearApi: rate limited, retry after #{retry_after || 'unknown'}s" }
|
|
493
|
+
raise RateLimitError.new("Rate limited by Linear API. Retry after #{retry_after || 60}s", retry_after: retry_after)
|
|
494
|
+
end
|
|
495
|
+
|
|
491
496
|
data = JSON.parse(response.body)
|
|
492
497
|
|
|
493
498
|
if data['errors']
|
|
494
499
|
error_message = data['errors'].map { |e| e['message'] }.join(', ')
|
|
500
|
+
LinearApi.logger.error { "LinearApi: GraphQL error: #{error_message}" }
|
|
495
501
|
return Result.new(success: false, error: error_message, raw_response: data)
|
|
496
502
|
end
|
|
497
503
|
|
|
498
504
|
Result.new(success: true, data: data['data'], raw_response: data)
|
|
499
505
|
rescue JSON::ParserError => e
|
|
506
|
+
LinearApi.logger.error { "LinearApi: invalid JSON response: #{e.message}" }
|
|
500
507
|
Result.new(success: false, error: "Invalid JSON: #{e.message}")
|
|
501
508
|
end
|
|
502
509
|
|
data/lib/linear_api/engine.rb
CHANGED