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.
@@ -3,6 +3,10 @@
3
3
  module LinearApi
4
4
  # Represents a Linear issue
5
5
  class Issue
6
+ # ==========================================================================
7
+ # GraphQL Mutations
8
+ # ==========================================================================
9
+
6
10
  CREATE_MUTATION = <<~GRAPHQL
7
11
  mutation CreateIssue($input: IssueCreateInput!) {
8
12
  issueCreate(input: $input) {
@@ -12,6 +16,9 @@ module LinearApi
12
16
  identifier
13
17
  title
14
18
  url
19
+ priority
20
+ dueDate
21
+ estimate
15
22
  state {
16
23
  id
17
24
  name
@@ -29,6 +36,9 @@ module LinearApi
29
36
  id
30
37
  identifier
31
38
  title
39
+ priority
40
+ dueDate
41
+ estimate
32
42
  state {
33
43
  id
34
44
  name
@@ -44,9 +54,55 @@ module LinearApi
44
54
  }
45
55
  GRAPHQL
46
56
 
47
- SEARCH_QUERY = <<~GRAPHQL
48
- query GetIssue($term: String!) {
49
- searchIssues(term: $term, first: 1) {
57
+ ADD_COMMENT_MUTATION = <<~GRAPHQL
58
+ mutation AddComment($issueId: String!, $body: String!) {
59
+ commentCreate(input: { issueId: $issueId, body: $body }) {
60
+ success
61
+ comment {
62
+ id
63
+ body
64
+ createdAt
65
+ }
66
+ }
67
+ }
68
+ GRAPHQL
69
+
70
+ ARCHIVE_MUTATION = <<~GRAPHQL
71
+ mutation ArchiveIssue($id: String!) {
72
+ issueArchive(id: $id) {
73
+ success
74
+ }
75
+ }
76
+ GRAPHQL
77
+
78
+ UNARCHIVE_MUTATION = <<~GRAPHQL
79
+ mutation UnarchiveIssue($id: String!) {
80
+ issueUnarchive(id: $id) {
81
+ success
82
+ }
83
+ }
84
+ GRAPHQL
85
+
86
+ LINK_PR_MUTATION = <<~GRAPHQL
87
+ mutation LinkPR($issueId: String!, $url: String!) {
88
+ attachmentLinkURL(issueId: $issueId, url: $url) {
89
+ success
90
+ attachment {
91
+ id
92
+ url
93
+ }
94
+ }
95
+ }
96
+ GRAPHQL
97
+
98
+ # ==========================================================================
99
+ # GraphQL Queries
100
+ # ==========================================================================
101
+
102
+ # Direct lookup by identifier (exact match, replaces search-based lookup)
103
+ GET_BY_IDENTIFIER_QUERY = <<~GRAPHQL
104
+ query GetIssueByIdentifier($filter: IssueFilter!) {
105
+ issues(filter: $filter, first: 1) {
50
106
  nodes {
51
107
  id
52
108
  identifier
@@ -54,6 +110,8 @@ module LinearApi
54
110
  description
55
111
  url
56
112
  priority
113
+ dueDate
114
+ estimate
57
115
  state {
58
116
  id
59
117
  name
@@ -88,6 +146,43 @@ module LinearApi
88
146
  }
89
147
  GRAPHQL
90
148
 
149
+ # Full-text search (use search_issues for fuzzy matching)
150
+ SEARCH_QUERY = <<~GRAPHQL
151
+ query SearchIssues($term: String!, $first: Int!) {
152
+ searchIssues(term: $term, first: $first) {
153
+ nodes {
154
+ id
155
+ identifier
156
+ title
157
+ description
158
+ url
159
+ priority
160
+ dueDate
161
+ estimate
162
+ state {
163
+ id
164
+ name
165
+ type
166
+ }
167
+ labels {
168
+ nodes {
169
+ id
170
+ name
171
+ }
172
+ }
173
+ assignee {
174
+ id
175
+ name
176
+ }
177
+ project {
178
+ id
179
+ name
180
+ }
181
+ }
182
+ }
183
+ }
184
+ GRAPHQL
185
+
91
186
  LIST_QUERY = <<~GRAPHQL
92
187
  query ListIssues($teamId: String!, $first: Int!) {
93
188
  team(id: $teamId) {
@@ -97,6 +192,8 @@ module LinearApi
97
192
  identifier
98
193
  title
99
194
  priority
195
+ dueDate
196
+ estimate
100
197
  state {
101
198
  id
102
199
  name
@@ -114,21 +211,128 @@ module LinearApi
114
211
  }
115
212
  GRAPHQL
116
213
 
117
- ADD_COMMENT_MUTATION = <<~GRAPHQL
118
- mutation AddComment($issueId: String!, $body: String!) {
119
- commentCreate(input: { issueId: $issueId, body: $body }) {
120
- success
121
- comment {
214
+ LIST_PAGINATED_QUERY = <<~GRAPHQL
215
+ query ListIssuesPaginated($teamId: String!, $first: Int!, $after: String) {
216
+ team(id: $teamId) {
217
+ issues(first: $first, after: $after, orderBy: updatedAt) {
218
+ nodes {
219
+ id
220
+ identifier
221
+ title
222
+ priority
223
+ dueDate
224
+ estimate
225
+ state {
226
+ id
227
+ name
228
+ type
229
+ }
230
+ labels {
231
+ nodes {
232
+ id
233
+ name
234
+ }
235
+ }
236
+ }
237
+ pageInfo {
238
+ hasNextPage
239
+ endCursor
240
+ }
241
+ }
242
+ }
243
+ }
244
+ GRAPHQL
245
+
246
+ GET_BY_ID_QUERY = <<~GRAPHQL
247
+ query GetIssueById($id: String!) {
248
+ issue(id: $id) {
249
+ id
250
+ identifier
251
+ title
252
+ description
253
+ url
254
+ priority
255
+ dueDate
256
+ estimate
257
+ state {
122
258
  id
123
- body
124
- createdAt
259
+ name
260
+ type
261
+ }
262
+ labels {
263
+ nodes {
264
+ id
265
+ name
266
+ }
267
+ }
268
+ assignee {
269
+ id
270
+ name
271
+ }
272
+ project {
273
+ id
274
+ name
275
+ }
276
+ }
277
+ }
278
+ GRAPHQL
279
+
280
+ BATCH_GET_QUERY = <<~GRAPHQL
281
+ query BatchGetIssues($filter: IssueFilter!) {
282
+ issues(filter: $filter) {
283
+ nodes {
284
+ id
285
+ identifier
286
+ title
287
+ priority
288
+ dueDate
289
+ estimate
290
+ state {
291
+ id
292
+ name
293
+ type
294
+ }
295
+ labels {
296
+ nodes {
297
+ id
298
+ name
299
+ }
300
+ }
301
+ assignee {
302
+ id
303
+ name
304
+ }
305
+ }
306
+ }
307
+ }
308
+ GRAPHQL
309
+
310
+ SUB_ISSUES_QUERY = <<~GRAPHQL
311
+ query GetSubIssues($id: String!) {
312
+ issue(id: $id) {
313
+ children {
314
+ nodes {
315
+ id
316
+ identifier
317
+ title
318
+ state {
319
+ id
320
+ name
321
+ }
322
+ priority
323
+ }
125
324
  }
126
325
  }
127
326
  }
128
327
  GRAPHQL
129
328
 
329
+ # ==========================================================================
330
+ # Model
331
+ # ==========================================================================
332
+
130
333
  attr_reader :id, :identifier, :title, :description, :url, :priority,
131
- :state, :labels, :assignee, :project, :comments, :raw
334
+ :state, :labels, :assignee, :project, :comments,
335
+ :due_date, :estimate, :raw
132
336
 
133
337
  def initialize(data)
134
338
  @raw = data
@@ -138,6 +342,8 @@ module LinearApi
138
342
  @description = data['description']
139
343
  @url = data['url']
140
344
  @priority = data['priority']
345
+ @due_date = data['dueDate']
346
+ @estimate = data['estimate']
141
347
  @state = data['state']
142
348
  @labels = (data.dig('labels', 'nodes') || [])
143
349
  @assignee = data['assignee']
@@ -161,6 +367,8 @@ module LinearApi
161
367
  description: description,
162
368
  url: url,
163
369
  priority: priority,
370
+ due_date: due_date,
371
+ estimate: estimate,
164
372
  state: state_name,
165
373
  labels: label_names
166
374
  }
@@ -23,7 +23,7 @@ module LinearApi
23
23
  existing = SyncedIssue.find_open_for_fingerprint(fingerprint)
24
24
  return add_occurrence_comment(existing, exception, context) if existing
25
25
 
26
- # Ensure auto-tracked project and label exist
26
+ # Ensure auto-tracked project and label exist (uses in-memory cache after first call)
27
27
  project_id = ensure_auto_tracked_project
28
28
  auto_label_id = ensure_auto_tracked_label
29
29
 
@@ -57,6 +57,10 @@ module LinearApi
57
57
  )
58
58
 
59
59
  Result.new(success: true, data: synced_issue)
60
+ rescue StandardError => e
61
+ # Never let error tracking failures propagate - log and return failure
62
+ LinearApi.logger.error { "LinearApi::IssueTracker: failed to track error: #{e.class} - #{e.message}" }
63
+ Result.new(success: false, error: "Error tracking failed: #{e.message}")
60
64
  end
61
65
 
62
66
  # Track a custom issue (not from exception)
@@ -98,10 +102,17 @@ module LinearApi
98
102
  )
99
103
 
100
104
  Result.new(success: true, data: synced_issue)
105
+ rescue StandardError => e
106
+ LinearApi.logger.error { "LinearApi::IssueTracker: failed to track issue: #{e.class} - #{e.message}" }
107
+ Result.new(success: false, error: "Issue tracking failed: #{e.message}")
101
108
  end
102
109
 
103
110
  # Setup auto-tracking infrastructure (project + label)
104
111
  def setup!
112
+ # Clear in-memory cache to force fresh lookup
113
+ @auto_tracked_project_id = nil
114
+ @auto_tracked_label_id = nil
115
+
105
116
  {
106
117
  project_id: ensure_auto_tracked_project,
107
118
  label_id: ensure_auto_tracked_label
@@ -127,11 +138,16 @@ module LinearApi
127
138
  labels.filter_map { |label| CachedMetadata.resolve_label_id(label) }
128
139
  end
129
140
 
130
- # Ensure the "Auto-Tracked Errors" project exists
141
+ # Ensure the "Auto-Tracked Errors" project exists (with in-memory memoization)
131
142
  def ensure_auto_tracked_project
132
- # Check cache first
143
+ return @auto_tracked_project_id if @auto_tracked_project_id
144
+
145
+ # Check DB cache first
133
146
  cached = CachedMetadata.projects.find_by(key: AUTO_TRACKED_PROJECT_KEY)
134
- return cached.linear_id if cached
147
+ if cached
148
+ @auto_tracked_project_id = cached.linear_id
149
+ return @auto_tracked_project_id
150
+ end
135
151
 
136
152
  # Check if project exists in Linear
137
153
  result = LinearApi.client.list_projects
@@ -139,7 +155,8 @@ module LinearApi
139
155
  existing = result.data.find { |p| p.name == AUTO_TRACKED_PROJECT_NAME }
140
156
  if existing
141
157
  cache_project(existing)
142
- return existing.id
158
+ @auto_tracked_project_id = existing.id
159
+ return @auto_tracked_project_id
143
160
  end
144
161
  end
145
162
 
@@ -153,14 +170,19 @@ module LinearApi
153
170
  return nil unless create_result.success?
154
171
 
155
172
  cache_project(create_result.data)
156
- create_result.data.id
173
+ @auto_tracked_project_id = create_result.data.id
157
174
  end
158
175
 
159
- # Ensure the "auto:tracked" label exists
176
+ # Ensure the "auto:tracked" label exists (with in-memory memoization)
160
177
  def ensure_auto_tracked_label
161
- # Check cache first
178
+ return @auto_tracked_label_id if @auto_tracked_label_id
179
+
180
+ # Check DB cache first
162
181
  cached = CachedMetadata.labels.find_by(key: AUTO_TRACKED_LABEL_NAME)
163
- return cached.linear_id if cached
182
+ if cached
183
+ @auto_tracked_label_id = cached.linear_id
184
+ return @auto_tracked_label_id
185
+ end
164
186
 
165
187
  # Check if label exists in Linear
166
188
  result = LinearApi.client.list_labels
@@ -168,7 +190,8 @@ module LinearApi
168
190
  existing = result.data.find { |l| l.name == AUTO_TRACKED_LABEL_NAME }
169
191
  if existing
170
192
  cache_label(existing)
171
- return existing.id
193
+ @auto_tracked_label_id = existing.id
194
+ return @auto_tracked_label_id
172
195
  end
173
196
  end
174
197
 
@@ -182,7 +205,7 @@ module LinearApi
182
205
  return nil unless create_result.success?
183
206
 
184
207
  cache_label(create_result.data)
185
- create_result.data.id
208
+ @auto_tracked_label_id = create_result.data.id
186
209
  end
187
210
 
188
211
  def cache_project(project)
@@ -253,16 +276,27 @@ module LinearApi
253
276
  end
254
277
 
255
278
  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
279
+ context.each_with_object({}) do |(key, value), hash|
280
+ key_str = key.to_s
281
+ if LinearApi::SENSITIVE_KEY_PATTERN.match?(key_str)
282
+ hash[key_str] = '[REDACTED]'
283
+ else
284
+ hash[key_str] = sanitize_value(value)
262
285
  end
263
286
  end
264
287
  end
265
288
 
289
+ def sanitize_value(value)
290
+ case value
291
+ when Time, DateTime then value.iso8601
292
+ when Date then value.to_s
293
+ when ActiveRecord::Base then "#{value.class.name}##{value.id}"
294
+ when Hash then sanitize_context(value)
295
+ when Array then value.map { |v| sanitize_value(v) }
296
+ else value
297
+ end
298
+ end
299
+
266
300
  def add_occurrence_comment(synced_issue, exception, context)
267
301
  comment_body = <<~MARKDOWN
268
302
  ## New Occurrence
@@ -18,6 +18,20 @@ module LinearApi
18
18
  }
19
19
  GRAPHQL
20
20
 
21
+ CREATE_MUTATION = <<~GRAPHQL
22
+ mutation CreateLabel($input: IssueLabelCreateInput!) {
23
+ issueLabelCreate(input: $input) {
24
+ success
25
+ issueLabel {
26
+ id
27
+ name
28
+ color
29
+ description
30
+ }
31
+ }
32
+ }
33
+ GRAPHQL
34
+
21
35
  attr_reader :id, :name, :color, :description, :raw
22
36
 
23
37
  def initialize(data)
@@ -13,13 +13,31 @@ module LinearApi
13
13
  description
14
14
  state
15
15
  progress
16
+ url
16
17
  }
17
18
  }
18
19
  }
19
20
  }
20
21
  GRAPHQL
21
22
 
22
- attr_reader :id, :name, :description, :state, :progress, :raw
23
+ CREATE_MUTATION = <<~GRAPHQL
24
+ mutation CreateProject($input: ProjectCreateInput!) {
25
+ projectCreate(input: $input) {
26
+ success
27
+ project {
28
+ id
29
+ name
30
+ description
31
+ url
32
+ color
33
+ state
34
+ progress
35
+ }
36
+ }
37
+ }
38
+ GRAPHQL
39
+
40
+ attr_reader :id, :name, :description, :state, :progress, :url, :color, :raw
23
41
 
24
42
  def initialize(data)
25
43
  @raw = data
@@ -28,6 +46,8 @@ module LinearApi
28
46
  @description = data['description']
29
47
  @state = data['state']
30
48
  @progress = data['progress']
49
+ @url = data['url']
50
+ @color = data['color']
31
51
  end
32
52
 
33
53
  def to_h
@@ -36,7 +56,9 @@ module LinearApi
36
56
  name: name,
37
57
  description: description,
38
58
  state: state,
39
- progress: progress
59
+ progress: progress,
60
+ url: url,
61
+ color: color
40
62
  }
41
63
  end
42
64
  end
@@ -2,6 +2,21 @@
2
2
 
3
3
  module LinearApi
4
4
  # Result object for API operations
5
+ #
6
+ # Always check success?/failure? before accessing data/error:
7
+ #
8
+ # result = client.create_issue(title: 'Bug')
9
+ # if result.success?
10
+ # puts result.data.identifier
11
+ # else
12
+ # puts result.error
13
+ # end
14
+ #
15
+ # Or use value! to raise on failure:
16
+ #
17
+ # issue = client.create_issue(title: 'Bug').value!
18
+ # puts issue.identifier
19
+ #
5
20
  class Result
6
21
  attr_reader :data, :error, :raw_response
7
22
 
@@ -20,17 +35,14 @@ module LinearApi
20
35
  !@success
21
36
  end
22
37
 
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
38
+ # Returns data on success, raises LinearApi::Error on failure
39
+ #
40
+ # @return [Object] the result data
41
+ # @raise [LinearApi::Error] if the result is a failure
42
+ def value!
43
+ raise LinearApi::Error, error if failure?
31
44
 
32
- def respond_to_missing?(method, include_private = false)
33
- data.respond_to?(method, include_private) || super
45
+ data
34
46
  end
35
47
  end
36
48
  end
@@ -23,6 +23,66 @@ module LinearApi
23
23
  name
24
24
  type
25
25
  position
26
+ color
27
+ }
28
+ }
29
+ }
30
+ }
31
+ GRAPHQL
32
+
33
+ USERS_QUERY = <<~GRAPHQL
34
+ query ListUsers($teamId: String!) {
35
+ team(id: $teamId) {
36
+ members {
37
+ nodes {
38
+ id
39
+ name
40
+ email
41
+ displayName
42
+ active
43
+ }
44
+ }
45
+ }
46
+ }
47
+ GRAPHQL
48
+
49
+ # Fetch all team metadata in a single query (for CacheSync)
50
+ ALL_METADATA_QUERY = <<~GRAPHQL
51
+ query FetchAllMetadata($teamId: String!) {
52
+ team(id: $teamId) {
53
+ labels {
54
+ nodes {
55
+ id
56
+ name
57
+ color
58
+ description
59
+ }
60
+ }
61
+ projects {
62
+ nodes {
63
+ id
64
+ name
65
+ description
66
+ state
67
+ progress
68
+ }
69
+ }
70
+ states {
71
+ nodes {
72
+ id
73
+ name
74
+ type
75
+ position
76
+ color
77
+ }
78
+ }
79
+ members {
80
+ nodes {
81
+ id
82
+ name
83
+ email
84
+ displayName
85
+ active
26
86
  }
27
87
  }
28
88
  }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module LinearApi
4
- VERSION = '0.3.2'
4
+ VERSION = '0.5.0'
5
5
  end