linear_api 0.3.2 → 0.5.1
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 +120 -14
- 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 +261 -229
- 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,29 @@ 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
|
+
# Parses the identifier into team key and issue number, then filters
|
|
99
|
+
# using the `number` and `team.key` fields on IssueFilter (Linear's
|
|
100
|
+
# GraphQL schema does not expose an `identifier` filter).
|
|
101
|
+
#
|
|
102
|
+
# @param identifier [String] Issue identifier (e.g., "TOS-123")
|
|
75
103
|
# @return [Result] Result with issue
|
|
76
104
|
def get_issue(identifier)
|
|
77
|
-
|
|
105
|
+
team_key, number = parse_identifier(identifier)
|
|
106
|
+
unless team_key && number
|
|
107
|
+
return Result.new(success: false, error: "Invalid identifier format: #{identifier} (expected TEAM-123)")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
filter = {
|
|
111
|
+
number: { eq: number },
|
|
112
|
+
team: { key: { eq: team_key } }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
result = query(Issue::GET_BY_IDENTIFIER_QUERY, variables: { filter: filter })
|
|
78
116
|
return result if result.failure?
|
|
79
117
|
|
|
80
|
-
issues = result.data.dig('
|
|
118
|
+
issues = result.data.dig('issues', 'nodes') || []
|
|
81
119
|
if issues.empty?
|
|
82
120
|
Result.new(success: false, error: "Issue not found: #{identifier}")
|
|
83
121
|
else
|
|
@@ -85,6 +123,19 @@ module LinearApi
|
|
|
85
123
|
end
|
|
86
124
|
end
|
|
87
125
|
|
|
126
|
+
# Search issues by text query (full-text search)
|
|
127
|
+
#
|
|
128
|
+
# @param term [String] Search term
|
|
129
|
+
# @param limit [Integer] Max results
|
|
130
|
+
# @return [Result] Result with array of issues
|
|
131
|
+
def search_issues(term:, limit: 10)
|
|
132
|
+
result = query(Issue::SEARCH_QUERY, variables: { term: term, first: limit })
|
|
133
|
+
return result if result.failure?
|
|
134
|
+
|
|
135
|
+
issues = (result.data.dig('searchIssues', 'nodes') || []).map { |i| Issue.new(i) }
|
|
136
|
+
Result.new(success: true, data: issues, raw_response: result.raw_response)
|
|
137
|
+
end
|
|
138
|
+
|
|
88
139
|
# List issues for the team
|
|
89
140
|
#
|
|
90
141
|
# @param filter [Hash] Filter options
|
|
@@ -101,6 +152,39 @@ module LinearApi
|
|
|
101
152
|
Result.new(success: true, data: issues, raw_response: result.raw_response)
|
|
102
153
|
end
|
|
103
154
|
|
|
155
|
+
# List all issues with automatic pagination
|
|
156
|
+
#
|
|
157
|
+
# @param filter [Hash] Filter options
|
|
158
|
+
# @param batch_size [Integer] Items per page
|
|
159
|
+
# @yield [Issue] Each issue as it's fetched (optional)
|
|
160
|
+
# @return [Result] Result with array of all issues
|
|
161
|
+
def list_all_issues(filter: nil, batch_size: 50, &block)
|
|
162
|
+
all_issues = []
|
|
163
|
+
cursor = nil
|
|
164
|
+
|
|
165
|
+
loop do
|
|
166
|
+
variables = { teamId: team_id, first: batch_size }
|
|
167
|
+
variables[:filter] = filter if filter
|
|
168
|
+
variables[:after] = cursor if cursor
|
|
169
|
+
|
|
170
|
+
result = query(Issue::LIST_PAGINATED_QUERY, variables: variables)
|
|
171
|
+
return result if result.failure?
|
|
172
|
+
|
|
173
|
+
nodes = (result.data.dig('team', 'issues', 'nodes') || []).map { |i| Issue.new(i) }
|
|
174
|
+
nodes.each do |issue|
|
|
175
|
+
block&.call(issue)
|
|
176
|
+
all_issues << issue
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
page_info = result.data.dig('team', 'issues', 'pageInfo')
|
|
180
|
+
break unless page_info&.dig('hasNextPage')
|
|
181
|
+
|
|
182
|
+
cursor = page_info['endCursor']
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
Result.new(success: true, data: all_issues)
|
|
186
|
+
end
|
|
187
|
+
|
|
104
188
|
# Add a comment to an issue
|
|
105
189
|
#
|
|
106
190
|
# @param issue_id [String] Issue ID
|
|
@@ -117,89 +201,88 @@ module LinearApi
|
|
|
117
201
|
)
|
|
118
202
|
end
|
|
119
203
|
|
|
120
|
-
# Get
|
|
204
|
+
# Get issue by UUID (not identifier)
|
|
121
205
|
#
|
|
122
|
-
# @
|
|
123
|
-
|
|
124
|
-
|
|
206
|
+
# @param id [String] Issue UUID
|
|
207
|
+
# @return [Result] Result with issue
|
|
208
|
+
def get_issue_by_id(id)
|
|
209
|
+
result = query(Issue::GET_BY_ID_QUERY, variables: { id: id })
|
|
125
210
|
return result if result.failure?
|
|
126
211
|
|
|
127
|
-
|
|
128
|
-
|
|
212
|
+
issue_data = result.data['issue']
|
|
213
|
+
if issue_data.nil?
|
|
214
|
+
Result.new(success: false, error: "Issue not found: #{id}")
|
|
215
|
+
else
|
|
216
|
+
Result.new(success: true, data: Issue.new(issue_data), raw_response: result.raw_response)
|
|
217
|
+
end
|
|
129
218
|
end
|
|
130
219
|
|
|
131
|
-
#
|
|
220
|
+
# Batch fetch issues by IDs (avoids N+1 API calls)
|
|
132
221
|
#
|
|
133
|
-
# @
|
|
134
|
-
|
|
135
|
-
|
|
222
|
+
# @param ids [Array<String>] Issue UUIDs
|
|
223
|
+
# @return [Result] Result with array of issues
|
|
224
|
+
def batch_get_issues(ids:)
|
|
225
|
+
result = query(Issue::BATCH_GET_QUERY, variables: { filter: { id: { in: ids } } })
|
|
136
226
|
return result if result.failure?
|
|
137
227
|
|
|
138
|
-
|
|
139
|
-
Result.new(success: true, data:
|
|
228
|
+
issues = (result.data.dig('issues', 'nodes') || []).map { |i| Issue.new(i) }
|
|
229
|
+
Result.new(success: true, data: issues, raw_response: result.raw_response)
|
|
140
230
|
end
|
|
141
231
|
|
|
142
|
-
#
|
|
232
|
+
# Archive an issue (soft delete)
|
|
143
233
|
#
|
|
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 })
|
|
234
|
+
# @param id [String] Issue ID
|
|
235
|
+
# @return [Result] Result with success status
|
|
236
|
+
def archive_issue(id:)
|
|
237
|
+
result = query(Issue::ARCHIVE_MUTATION, variables: { id: id })
|
|
169
238
|
return result if result.failure?
|
|
170
239
|
|
|
171
|
-
project_data = result.data.dig('projectCreate', 'project')
|
|
172
240
|
Result.new(
|
|
173
|
-
success: result.data.dig('
|
|
174
|
-
data:
|
|
241
|
+
success: result.data.dig('issueArchive', 'success'),
|
|
242
|
+
data: { archived: true },
|
|
175
243
|
raw_response: result.raw_response
|
|
176
244
|
)
|
|
177
245
|
end
|
|
178
246
|
|
|
179
|
-
#
|
|
247
|
+
# Unarchive an issue
|
|
180
248
|
#
|
|
181
|
-
# @
|
|
182
|
-
|
|
183
|
-
|
|
249
|
+
# @param id [String] Issue ID
|
|
250
|
+
# @return [Result] Result with success status
|
|
251
|
+
def unarchive_issue(id:)
|
|
252
|
+
result = query(Issue::UNARCHIVE_MUTATION, variables: { id: id })
|
|
184
253
|
return result if result.failure?
|
|
185
254
|
|
|
186
|
-
|
|
187
|
-
|
|
255
|
+
Result.new(
|
|
256
|
+
success: result.data.dig('issueUnarchive', 'success'),
|
|
257
|
+
data: { archived: false },
|
|
258
|
+
raw_response: result.raw_response
|
|
259
|
+
)
|
|
188
260
|
end
|
|
189
261
|
|
|
190
|
-
#
|
|
262
|
+
# Create a sub-issue (child issue)
|
|
191
263
|
#
|
|
192
|
-
# @
|
|
193
|
-
|
|
194
|
-
|
|
264
|
+
# @param parent_id [String] Parent issue ID
|
|
265
|
+
# @param title [String] Sub-issue title
|
|
266
|
+
# @param options [Hash] Additional options
|
|
267
|
+
# @return [Result] Result with created sub-issue
|
|
268
|
+
def create_sub_issue(parent_id:, title:, **options)
|
|
269
|
+
create_issue(title: title, parent_id: parent_id, **options)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Get sub-issues for an issue
|
|
273
|
+
#
|
|
274
|
+
# @param parent_id [String] Parent issue ID
|
|
275
|
+
# @return [Result] Result with array of sub-issues
|
|
276
|
+
def list_sub_issues(parent_id:)
|
|
277
|
+
result = query(Issue::SUB_ISSUES_QUERY, variables: { id: parent_id })
|
|
195
278
|
return result if result.failure?
|
|
196
279
|
|
|
197
|
-
|
|
198
|
-
Result.new(success: true, data:
|
|
280
|
+
children = (result.data.dig('issue', 'children', 'nodes') || []).map { |i| Issue.new(i) }
|
|
281
|
+
Result.new(success: true, data: children, raw_response: result.raw_response)
|
|
199
282
|
end
|
|
200
283
|
|
|
201
284
|
# ==========================================================================
|
|
202
|
-
# Convenience Methods for Common Operations
|
|
285
|
+
# Convenience Methods for Common Issue Operations
|
|
203
286
|
# ==========================================================================
|
|
204
287
|
|
|
205
288
|
# Move an issue to a different state
|
|
@@ -217,7 +300,6 @@ module LinearApi
|
|
|
217
300
|
# @param label_ids [Array<String>] Label IDs to add
|
|
218
301
|
# @return [Result] Result with updated issue
|
|
219
302
|
def add_labels(id:, label_ids:)
|
|
220
|
-
# First get current labels
|
|
221
303
|
issue_result = get_issue_by_id(id)
|
|
222
304
|
return issue_result if issue_result.failure?
|
|
223
305
|
|
|
@@ -257,19 +339,7 @@ module LinearApi
|
|
|
257
339
|
# @param pr_url [String] GitHub PR URL
|
|
258
340
|
# @return [Result] Result with attachment
|
|
259
341
|
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 })
|
|
342
|
+
result = query(Issue::LINK_PR_MUTATION, variables: { issueId: issue_id, url: pr_url })
|
|
273
343
|
return result if result.failure?
|
|
274
344
|
|
|
275
345
|
Result.new(
|
|
@@ -279,79 +349,112 @@ module LinearApi
|
|
|
279
349
|
)
|
|
280
350
|
end
|
|
281
351
|
|
|
352
|
+
# ==========================================================================
|
|
353
|
+
# Team Operations
|
|
354
|
+
# ==========================================================================
|
|
355
|
+
|
|
356
|
+
# Get team info
|
|
357
|
+
#
|
|
358
|
+
# @return [Result] Result with team data
|
|
359
|
+
def get_team
|
|
360
|
+
result = query(Team::GET_QUERY, variables: { teamId: team_id })
|
|
361
|
+
return result if result.failure?
|
|
362
|
+
|
|
363
|
+
team_data = result.data['team']
|
|
364
|
+
Result.new(success: true, data: Team.new(team_data), raw_response: result.raw_response)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Get workflow states for the team
|
|
368
|
+
#
|
|
369
|
+
# @return [Result] Result with array of states
|
|
370
|
+
def list_states
|
|
371
|
+
result = query(Team::STATES_QUERY, variables: { teamId: team_id })
|
|
372
|
+
return result if result.failure?
|
|
373
|
+
|
|
374
|
+
states = result.data.dig('team', 'states', 'nodes') || []
|
|
375
|
+
Result.new(success: true, data: states, raw_response: result.raw_response)
|
|
376
|
+
end
|
|
377
|
+
|
|
282
378
|
# List team members for assignment
|
|
283
379
|
#
|
|
284
380
|
# @return [Result] Result with array of users
|
|
285
381
|
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 })
|
|
382
|
+
result = query(Team::USERS_QUERY, variables: { teamId: team_id })
|
|
303
383
|
return result if result.failure?
|
|
304
384
|
|
|
305
385
|
users = result.data.dig('team', 'members', 'nodes') || []
|
|
306
386
|
Result.new(success: true, data: users, raw_response: result.raw_response)
|
|
307
387
|
end
|
|
308
388
|
|
|
309
|
-
#
|
|
389
|
+
# Fetch all team metadata in a single API call (labels, projects, states, users)
|
|
310
390
|
#
|
|
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 })
|
|
391
|
+
# @return [Result] Result with hash of { labels:, projects:, states:, users: }
|
|
392
|
+
def fetch_all_metadata
|
|
393
|
+
result = query(Team::ALL_METADATA_QUERY, variables: { teamId: team_id })
|
|
323
394
|
return result if result.failure?
|
|
324
395
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
396
|
+
team = result.data['team']
|
|
397
|
+
data = {
|
|
398
|
+
labels: (team.dig('labels', 'nodes') || []).map { |l| Label.new(l) },
|
|
399
|
+
projects: (team.dig('projects', 'nodes') || []).map { |p| Project.new(p) },
|
|
400
|
+
states: team.dig('states', 'nodes') || [],
|
|
401
|
+
users: team.dig('members', 'nodes') || []
|
|
402
|
+
}
|
|
403
|
+
Result.new(success: true, data: data, raw_response: result.raw_response)
|
|
330
404
|
end
|
|
331
405
|
|
|
332
|
-
#
|
|
406
|
+
# ==========================================================================
|
|
407
|
+
# Project Operations
|
|
408
|
+
# ==========================================================================
|
|
409
|
+
|
|
410
|
+
# List projects for the team
|
|
333
411
|
#
|
|
334
|
-
# @
|
|
335
|
-
|
|
336
|
-
|
|
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 })
|
|
412
|
+
# @return [Result] Result with array of projects
|
|
413
|
+
def list_projects
|
|
414
|
+
result = query(Project::LIST_QUERY, variables: { teamId: team_id })
|
|
346
415
|
return result if result.failure?
|
|
347
416
|
|
|
417
|
+
projects = (result.data.dig('team', 'projects', 'nodes') || []).map { |p| Project.new(p) }
|
|
418
|
+
Result.new(success: true, data: projects, raw_response: result.raw_response)
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Create a new project
|
|
422
|
+
#
|
|
423
|
+
# @param name [String] Project name
|
|
424
|
+
# @param description [String] Optional project description
|
|
425
|
+
# @param color [String] Optional hex color (e.g., "#0052CC")
|
|
426
|
+
# @return [Result] Result with created project
|
|
427
|
+
def create_project(name:, description: nil, color: nil)
|
|
428
|
+
input = { name: name, teamIds: [team_id] }
|
|
429
|
+
input[:description] = description if description
|
|
430
|
+
input[:color] = color if color
|
|
431
|
+
|
|
432
|
+
result = query(Project::CREATE_MUTATION, variables: { input: input })
|
|
433
|
+
return result if result.failure?
|
|
434
|
+
|
|
435
|
+
project_data = result.data.dig('projectCreate', 'project')
|
|
348
436
|
Result.new(
|
|
349
|
-
success: result.data.dig('
|
|
350
|
-
data:
|
|
437
|
+
success: result.data.dig('projectCreate', 'success'),
|
|
438
|
+
data: Project.new(project_data),
|
|
351
439
|
raw_response: result.raw_response
|
|
352
440
|
)
|
|
353
441
|
end
|
|
354
442
|
|
|
443
|
+
# ==========================================================================
|
|
444
|
+
# Label Operations
|
|
445
|
+
# ==========================================================================
|
|
446
|
+
|
|
447
|
+
# List labels for the team
|
|
448
|
+
#
|
|
449
|
+
# @return [Result] Result with array of labels
|
|
450
|
+
def list_labels
|
|
451
|
+
result = query(Label::LIST_QUERY, variables: { teamId: team_id })
|
|
452
|
+
return result if result.failure?
|
|
453
|
+
|
|
454
|
+
labels = (result.data.dig('team', 'labels', 'nodes') || []).map { |l| Label.new(l) }
|
|
455
|
+
Result.new(success: true, data: labels, raw_response: result.raw_response)
|
|
456
|
+
end
|
|
457
|
+
|
|
355
458
|
# Create a new label
|
|
356
459
|
#
|
|
357
460
|
# @param name [String] Label name (e.g., "type:bug", "area:sync")
|
|
@@ -359,25 +462,11 @@ module LinearApi
|
|
|
359
462
|
# @param description [String] Optional description
|
|
360
463
|
# @return [Result] Result with created label
|
|
361
464
|
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
465
|
input = { name: name, teamId: team_id }
|
|
377
466
|
input[:color] = color if color
|
|
378
467
|
input[:description] = description if description
|
|
379
468
|
|
|
380
|
-
result = query(
|
|
469
|
+
result = query(Label::CREATE_MUTATION, variables: { input: input })
|
|
381
470
|
return result if result.failure?
|
|
382
471
|
|
|
383
472
|
label_data = result.data.dig('issueLabelCreate', 'issueLabel')
|
|
@@ -388,99 +477,22 @@ module LinearApi
|
|
|
388
477
|
)
|
|
389
478
|
end
|
|
390
479
|
|
|
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
480
|
private
|
|
481
481
|
|
|
482
482
|
def connection
|
|
483
483
|
@connection ||= Faraday.new(url: API_ENDPOINT) do |f|
|
|
484
|
+
f.options.timeout = @read_timeout
|
|
485
|
+
f.options.open_timeout = @open_timeout
|
|
486
|
+
f.request :retry, max: @max_retries,
|
|
487
|
+
interval: DEFAULT_RETRY_INTERVAL,
|
|
488
|
+
backoff_factor: 2,
|
|
489
|
+
exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed],
|
|
490
|
+
retry_statuses: [500, 502, 503],
|
|
491
|
+
retry_block: lambda { |env, _opts, retries, exc|
|
|
492
|
+
LinearApi.logger.warn do
|
|
493
|
+
"LinearApi: retry #{retries}/#{@max_retries} after #{exc&.class || "HTTP #{env&.status}"}"
|
|
494
|
+
end
|
|
495
|
+
}
|
|
484
496
|
f.headers['Content-Type'] = 'application/json'
|
|
485
497
|
f.headers['Authorization'] = api_key
|
|
486
498
|
f.adapter Faraday.default_adapter
|
|
@@ -488,15 +500,24 @@ module LinearApi
|
|
|
488
500
|
end
|
|
489
501
|
|
|
490
502
|
def handle_response(response)
|
|
503
|
+
# Check for rate limiting
|
|
504
|
+
if response.status == 429
|
|
505
|
+
retry_after = response.headers['retry-after']&.to_i
|
|
506
|
+
LinearApi.logger.warn { "LinearApi: rate limited, retry after #{retry_after || 'unknown'}s" }
|
|
507
|
+
raise RateLimitError.new("Rate limited by Linear API. Retry after #{retry_after || 60}s", retry_after: retry_after)
|
|
508
|
+
end
|
|
509
|
+
|
|
491
510
|
data = JSON.parse(response.body)
|
|
492
511
|
|
|
493
512
|
if data['errors']
|
|
494
513
|
error_message = data['errors'].map { |e| e['message'] }.join(', ')
|
|
514
|
+
LinearApi.logger.error { "LinearApi: GraphQL error: #{error_message}" }
|
|
495
515
|
return Result.new(success: false, error: error_message, raw_response: data)
|
|
496
516
|
end
|
|
497
517
|
|
|
498
518
|
Result.new(success: true, data: data['data'], raw_response: data)
|
|
499
519
|
rescue JSON::ParserError => e
|
|
520
|
+
LinearApi.logger.error { "LinearApi: invalid JSON response: #{e.message}" }
|
|
500
521
|
Result.new(success: false, error: "Invalid JSON: #{e.message}")
|
|
501
522
|
end
|
|
502
523
|
|
|
@@ -512,6 +533,17 @@ module LinearApi
|
|
|
512
533
|
input
|
|
513
534
|
end
|
|
514
535
|
|
|
536
|
+
# Parse an identifier like "TOS-123" into ["TOS", 123]
|
|
537
|
+
#
|
|
538
|
+
# @param identifier [String] e.g. "TOS-123"
|
|
539
|
+
# @return [Array(String, Integer), Array(nil, nil)] team key and number, or nils
|
|
540
|
+
def parse_identifier(identifier)
|
|
541
|
+
match = identifier&.match(/\A([A-Za-z]+)-(\d+)\z/)
|
|
542
|
+
return [nil, nil] unless match
|
|
543
|
+
|
|
544
|
+
[match[1].upcase, match[2].to_i]
|
|
545
|
+
end
|
|
546
|
+
|
|
515
547
|
def camelize(key)
|
|
516
548
|
key.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }.to_sym
|
|
517
549
|
end
|
data/lib/linear_api/engine.rb
CHANGED