collavre_linear 0.1.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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/app/controllers/collavre_linear/application_controller.rb +21 -0
  3. data/app/controllers/collavre_linear/auth_controller.rb +135 -0
  4. data/app/controllers/collavre_linear/creatives/integrations_controller.rb +271 -0
  5. data/app/controllers/collavre_linear/webhooks_controller.rb +187 -0
  6. data/app/javascript/collavre_linear.js +199 -0
  7. data/app/jobs/collavre_linear/inbound_apply_job.rb +36 -0
  8. data/app/jobs/collavre_linear/outbound_archive_job.rb +29 -0
  9. data/app/jobs/collavre_linear/outbound_comment_delete_job.rb +41 -0
  10. data/app/jobs/collavre_linear/outbound_comment_sync_job.rb +112 -0
  11. data/app/jobs/collavre_linear/outbound_comment_update_job.rb +55 -0
  12. data/app/jobs/collavre_linear/outbound_sync_job.rb +39 -0
  13. data/app/models/collavre_linear/account.rb +29 -0
  14. data/app/models/collavre_linear/application_record.rb +7 -0
  15. data/app/models/collavre_linear/comment_link.rb +12 -0
  16. data/app/models/collavre_linear/issue_link.rb +20 -0
  17. data/app/models/collavre_linear/project_link.rb +76 -0
  18. data/app/observers/collavre_linear/comment_sync_observer.rb +97 -0
  19. data/app/observers/collavre_linear/creative_sync_observer.rb +162 -0
  20. data/app/services/collavre_linear/client.rb +425 -0
  21. data/app/services/collavre_linear/comment_formatter.rb +34 -0
  22. data/app/services/collavre_linear/comment_syncability.rb +33 -0
  23. data/app/services/collavre_linear/creative_exporter.rb +352 -0
  24. data/app/services/collavre_linear/echo_guard.rb +36 -0
  25. data/app/services/collavre_linear/field_mapper.rb +119 -0
  26. data/app/services/collavre_linear/inbound_applier.rb +599 -0
  27. data/app/services/collavre_linear/o_auth_token_service.rb +163 -0
  28. data/app/views/collavre_linear/auth/setup.html.erb +63 -0
  29. data/app/views/collavre_linear/integrations/_modal.html.erb +200 -0
  30. data/config/initializers/integration_settings.rb +8 -0
  31. data/config/locales/en.yml +49 -0
  32. data/config/locales/ko.yml +49 -0
  33. data/config/routes.rb +22 -0
  34. data/db/migrate/20260701000000_create_linear_accounts.rb +20 -0
  35. data/db/migrate/20260701000001_create_collavre_linear_project_links.rb +18 -0
  36. data/db/migrate/20260701000002_create_collavre_linear_issue_links.rb +22 -0
  37. data/db/migrate/20260701000003_create_collavre_linear_comment_links.rb +17 -0
  38. data/db/migrate/20260701000004_add_last_outbound_at_to_linear_links.rb +6 -0
  39. data/db/migrate/20260701000005_add_webhook_id_to_linear_project_links.rb +5 -0
  40. data/db/migrate/20260701000006_add_lookup_indexes_to_linear_project_links.rb +17 -0
  41. data/db/migrate/20260701000007_enforce_one_project_link_per_creative.rb +19 -0
  42. data/db/migrate/20260701000008_allow_null_webhook_secret_on_linear_project_links.rb +8 -0
  43. data/lib/collavre_linear/engine.rb +122 -0
  44. data/lib/collavre_linear/version.rb +3 -0
  45. data/lib/collavre_linear.rb +5 -0
  46. metadata +115 -0
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Auto-triggers an outbound Linear comment sync when a human posts a chat
5
+ # message in the Main topic of a creative that is linked to a Linear issue.
6
+ #
7
+ # Mixed into Collavre::Comment via the engine's `to_prepare` block:
8
+ #
9
+ # Collavre::Comment.include CollavreLinear::CommentSyncObserver
10
+ #
11
+ # Mirrors CreativeSyncObserver: the transient `skip_linear_sync` flag lets the
12
+ # inbound applier suppress the echo when it mirrors a Linear comment locally
13
+ # (otherwise the mirrored comment would bounce straight back out to Linear).
14
+ module CommentSyncObserver
15
+ extend ActiveSupport::Concern
16
+
17
+ included do
18
+ # Transient, per-record suppression flag set by the inbound applier on the
19
+ # comment it mirrors from Linear so this after_commit does not echo it back.
20
+ attr_accessor :skip_linear_sync
21
+
22
+ after_create_commit :enqueue_linear_comment_sync
23
+ after_update_commit :enqueue_linear_comment_update
24
+ after_destroy_commit :enqueue_linear_comment_delete
25
+ end
26
+
27
+ private
28
+
29
+ def enqueue_linear_comment_sync
30
+ return unless linear_syncable_comment?
31
+
32
+ CollavreLinear::OutboundCommentSyncJob.perform_later(id)
33
+ rescue StandardError => e
34
+ log_enqueue_failure("sync", e)
35
+ end
36
+
37
+ # Propagate a local edit to Linear. A single core update can change content,
38
+ # `private`, and `topic_id` together, so re-check syncability: if the comment
39
+ # turned private or moved out of Main, remove its Linear mirror rather than
40
+ # pushing the now-hidden body outward. `skip_linear_sync` suppresses the echo
41
+ # when the inbound applier rewrites content from Linear.
42
+ def enqueue_linear_comment_update
43
+ return if skip_linear_sync
44
+
45
+ comment_link = CollavreLinear::CommentLink.find_by(comment_id: id)
46
+
47
+ unless comment_link
48
+ # No mirror: either never synced, or a prior update tore it down when the
49
+ # comment went private / left Main. If it is syncable again, recreate the
50
+ # mirror so re-entering visibility doesn't leave it permanently off Linear.
51
+ # The sync job locks the IssueLink and re-checks CommentLink existence, so
52
+ # this can't duplicate a mirror the create path is still building.
53
+ CollavreLinear::OutboundCommentSyncJob.perform_later(id) if linear_syncable_comment?
54
+ return
55
+ end
56
+
57
+ unless linear_syncable_comment?
58
+ CollavreLinear::OutboundCommentDeleteJob.perform_later(comment_link.id)
59
+ return
60
+ end
61
+
62
+ return unless saved_change_to_content?
63
+
64
+ CollavreLinear::OutboundCommentUpdateJob.perform_later(id)
65
+ rescue StandardError => e
66
+ log_enqueue_failure("update", e)
67
+ end
68
+
69
+ # Propagate a local deletion to Linear. The comment row is gone by the time
70
+ # this after-commit runs, so the CommentLink (not cascade-deleted — no FK on
71
+ # comment_id) is the only handle on the Linear comment id; the job consumes it.
72
+ def enqueue_linear_comment_delete
73
+ return if skip_linear_sync
74
+
75
+ comment_link = CollavreLinear::CommentLink.find_by(comment_id: id)
76
+ return unless comment_link
77
+
78
+ CollavreLinear::OutboundCommentDeleteJob.perform_later(comment_link.id)
79
+ rescue StandardError => e
80
+ log_enqueue_failure("delete", e)
81
+ end
82
+
83
+ def log_enqueue_failure(kind, error)
84
+ # Never let sync scheduling break the host transaction's commit callbacks.
85
+ Rails.logger.error(
86
+ "[CollavreLinear::CommentSyncObserver] failed to enqueue comment #{kind} for " \
87
+ "comment #{id}: #{error.class}: #{error.message}"
88
+ )
89
+ end
90
+
91
+ # Delegates to the shared predicate so the observer and the outbound update
92
+ # job (which re-checks at run time) can never diverge on what is syncable.
93
+ def linear_syncable_comment?
94
+ CollavreLinear::CommentSyncability.syncable?(self)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Auto-triggers an outbound Linear sync whenever a Collavre::Creative inside a
5
+ # linked subtree is created, updated, moved, or destroyed.
6
+ #
7
+ # Mixed into Collavre::Creative via the engine's `to_prepare` block:
8
+ #
9
+ # Collavre::Creative.include CollavreLinear::CreativeSyncObserver
10
+ #
11
+ # which registers `after_commit` and adds the transient `skip_linear_sync`
12
+ # accessor.
13
+ #
14
+ # Loop guard: the inbound applier (Task 10) sets `creative.skip_linear_sync =
15
+ # true` on records it mutates so the resulting after_commit does NOT bounce a
16
+ # change back to Linear. The flag is a per-record transient attribute (not
17
+ # persisted), so it only suppresses the single in-memory instance that applied
18
+ # the inbound change.
19
+ module CreativeSyncObserver
20
+ extend ActiveSupport::Concern
21
+
22
+ included do
23
+ # Transient, per-record suppression flag used by the inbound applier.
24
+ attr_accessor :skip_linear_sync
25
+
26
+ # Transient storage for archive data captured before the row is gone.
27
+ attr_accessor :_linear_archive_issue_id, :_linear_archive_account_id
28
+
29
+ # Transient flag: did the just-committed save touch a Linear-relevant
30
+ # column? Captured in after_save (where saved_changes is reliable) so the
31
+ # after_commit hook can short-circuit WITHOUT a DB query. saved_changes is
32
+ # cleared by the time after_commit runs (esp. under transactional tests),
33
+ # so it cannot be read there directly.
34
+ attr_accessor :_linear_relevant_change
35
+
36
+ # Transient flag: did this save re-parent the creative (parent_id change)?
37
+ # A move carries its whole subtree, but the core move hook touches
38
+ # descendants via `update_all` (no callbacks fire on them), so their sync
39
+ # must be fanned out from the moved root. Same capture-in-after_save reason
40
+ # as above.
41
+ attr_accessor :_linear_parent_changed
42
+
43
+ # prepend: true ensures we run before the dependent: :destroy cascade that
44
+ # deletes IssueLinks — which is added by a separate engine initializer.
45
+ before_destroy :capture_linear_archive_info, prepend: true
46
+ after_save :capture_linear_relevant_change
47
+ after_commit :enqueue_linear_outbound_sync
48
+ end
49
+
50
+ private
51
+
52
+ # Capture linear_issue_id and account_id before the creative (and its
53
+ # dependent IssueLink) are deleted from the DB. Stored in transient attrs
54
+ # so the after_commit destroy branch can read them without hitting a gone row.
55
+ def capture_linear_archive_info
56
+ issue_link = linear_issue_links.first
57
+ return unless issue_link
58
+
59
+ self._linear_archive_issue_id = issue_link.linear_issue_id
60
+ self._linear_archive_account_id = issue_link.project_link.account_id
61
+ rescue StandardError => e
62
+ Rails.logger.error(
63
+ "[CollavreLinear::CreativeSyncObserver] before_destroy capture failed for " \
64
+ "creative #{id}: #{e.class}: #{e.message}"
65
+ )
66
+ end
67
+
68
+ # Columns whose change can alter the exported Linear issue (mirrors what
69
+ # CreativeExporter hashes: description -> title/description, sequence ->
70
+ # priority, data -> state/labels) plus parent_id (re-parenting -> parentId).
71
+ LINEAR_RELEVANT_COLUMNS = %w[description sequence data parent_id].freeze
72
+
73
+ # Record — in after_save, where saved_changes is reliable — whether this
74
+ # save touched a Linear-relevant column. A create always counts (its seeded
75
+ # attributes populate the whole row).
76
+ def capture_linear_relevant_change
77
+ self._linear_relevant_change =
78
+ if previously_new_record?
79
+ true
80
+ else
81
+ (saved_changes.keys & LINEAR_RELEVANT_COLUMNS).any?
82
+ end
83
+ self._linear_parent_changed = saved_change_to_parent_id?
84
+ end
85
+
86
+ def enqueue_linear_outbound_sync
87
+ return if skip_linear_sync
88
+
89
+ # Destroys are driven ENTIRELY by what before_destroy captured. By the time
90
+ # this after_commit runs, the ProjectLink/IssueLink rows are already gone
91
+ # via dependent: :destroy, so `linked_subtree?` is unreliable here — it
92
+ # returns false for the link OWNER itself, which would otherwise swallow
93
+ # the captured archive id and leak a live Linear issue. Trust the captured
94
+ # ids; enqueue_archive_if_captured no-ops when nothing was captured (an
95
+ # unlinked creative), so this is safe for every destroy.
96
+ if destroyed?
97
+ enqueue_archive_if_captured
98
+ return
99
+ end
100
+
101
+ # Cheap in-memory short-circuit BEFORE any DB work: `after_commit` fires for
102
+ # every Creative write app-wide. An update/create with no Linear-relevant
103
+ # column change can't affect the exported issue, so skip the subtree query.
104
+ return if relevant_change_absent?
105
+ return unless linked_subtree?
106
+
107
+ mark_issue_link_dirty
108
+
109
+ # A re-parent moves this creative's whole subtree into the linked root, but
110
+ # the core move hook only `update_all`-touches descendants (no callbacks),
111
+ # so they never enqueue themselves. Fan out to each so pre-existing children
112
+ # reach Linear too. Per-creative jobs self-order via the exporter's
113
+ # ParentNotExportedError retry, so enqueue order doesn't matter. Non-move
114
+ # changes (description/data/sequence) only affect this one creative.
115
+ target_ids = _linear_parent_changed ? self_and_descendants.ids : [ id ]
116
+ target_ids.each { |cid| CollavreLinear::OutboundSyncJob.perform_later(cid) }
117
+ rescue StandardError => e
118
+ # Never let a sync-scheduling failure break the host transaction's
119
+ # commit callbacks.
120
+ Rails.logger.error(
121
+ "[CollavreLinear::CreativeSyncObserver] failed to enqueue sync for " \
122
+ "creative #{id}: #{e.class}: #{e.message}"
123
+ )
124
+ end
125
+
126
+ # True when this commit touched no Linear-relevant column. Only reached for
127
+ # non-destroy commits (destroys return early via the archive path). Reads
128
+ # the flag captured in after_save; a nil flag (no save ran, e.g. a bare
129
+ # touch) is treated as "no relevant change".
130
+ def relevant_change_absent?
131
+ !_linear_relevant_change
132
+ end
133
+
134
+ def enqueue_archive_if_captured
135
+ return unless _linear_archive_issue_id && _linear_archive_account_id
136
+
137
+ CollavreLinear::OutboundArchiveJob.perform_later(
138
+ _linear_archive_issue_id,
139
+ _linear_archive_account_id
140
+ )
141
+ end
142
+
143
+ # Mark the IssueLink dirty so the exporter knows a sync is pending.
144
+ # Only applicable when a link already exists; new creatives have no link yet
145
+ # (linear_issue_id is NOT NULL so a link cannot be created before the API call).
146
+ def mark_issue_link_dirty
147
+ issue_link = linear_issue_links.first
148
+ issue_link&.update_column(:sync_state, CollavreLinear::IssueLink.sync_states[:dirty])
149
+ end
150
+
151
+ # True when this creative (or any ancestor) carries a ProjectLink, i.e. it
152
+ # lives inside a subtree that is linked to a Linear project. Only called for
153
+ # non-destroy commits — the row is still present, so `self_and_ancestors` is
154
+ # queryable.
155
+ def linked_subtree?
156
+ ancestor_ids = self_and_ancestors.pluck(:id)
157
+ return false if ancestor_ids.empty?
158
+
159
+ CollavreLinear::ProjectLink.where(creative_id: ancestor_ids).exists?
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,425 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module CollavreLinear
8
+ class Client
9
+ # Raised when the GraphQL response includes an `errors` array.
10
+ class Error < StandardError; end
11
+
12
+ DEFAULT_ENDPOINT = "https://api.linear.app/graphql"
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # GraphQL operation strings
16
+ # NOTE: All *Input field names below must be verified against the Live Linear
17
+ # Apollo schema before production use. Field names are conventional based on
18
+ # the public Linear GraphQL documentation but have NOT been validated against
19
+ # the live schema in this implementation.
20
+ # ---------------------------------------------------------------------------
21
+
22
+ # IssueCreateInput fields used: teamId, title, description, parentId,
23
+ # projectId, stateId, assigneeId, labelIds, priority
24
+ ISSUE_CREATE = <<~GQL.freeze
25
+ mutation IssueCreate($input: IssueCreateInput!) {
26
+ issueCreate(input: $input) {
27
+ success
28
+ issue {
29
+ id
30
+ identifier
31
+ updatedAt
32
+ }
33
+ }
34
+ }
35
+ GQL
36
+
37
+ # IssueUpdateInput fields used: title, description, parentId, projectId,
38
+ # stateId, assigneeId, labelIds, priority (any subset passed as **fields)
39
+ ISSUE_UPDATE = <<~GQL.freeze
40
+ mutation IssueUpdate($id: String!, $input: IssueUpdateInput!) {
41
+ issueUpdate(id: $id, input: $input) {
42
+ success
43
+ issue {
44
+ id
45
+ identifier
46
+ updatedAt
47
+ }
48
+ }
49
+ }
50
+ GQL
51
+
52
+ # ProjectCreateInput fields used: name, teamIds
53
+ PROJECT_CREATE = <<~GQL.freeze
54
+ mutation ProjectCreate($input: ProjectCreateInput!) {
55
+ projectCreate(input: $input) {
56
+ success
57
+ project {
58
+ id
59
+ }
60
+ }
61
+ }
62
+ GQL
63
+
64
+ # ProjectUpdateInput fields used: name (any subset passed as **fields)
65
+ PROJECT_UPDATE = <<~GQL.freeze
66
+ mutation ProjectUpdate($id: String!, $input: ProjectUpdateInput!) {
67
+ projectUpdate(id: $id, input: $input) {
68
+ success
69
+ project {
70
+ id
71
+ }
72
+ }
73
+ }
74
+ GQL
75
+
76
+ # CommentCreateInput fields used: issueId, body
77
+ COMMENT_CREATE = <<~GQL.freeze
78
+ mutation CommentCreate($input: CommentCreateInput!) {
79
+ commentCreate(input: $input) {
80
+ success
81
+ comment {
82
+ id
83
+ updatedAt
84
+ }
85
+ }
86
+ }
87
+ GQL
88
+
89
+ # CommentUpdateInput fields used: body
90
+ COMMENT_UPDATE = <<~GQL.freeze
91
+ mutation CommentUpdate($id: String!, $input: CommentUpdateInput!) {
92
+ commentUpdate(id: $id, input: $input) {
93
+ success
94
+ comment {
95
+ id
96
+ updatedAt
97
+ }
98
+ }
99
+ }
100
+ GQL
101
+
102
+ COMMENT_DELETE = <<~GQL.freeze
103
+ mutation CommentDelete($id: String!) {
104
+ commentDelete(id: $id) {
105
+ success
106
+ }
107
+ }
108
+ GQL
109
+
110
+ # WebhookCreateInput fields used: url, secret, teamId, resourceTypes
111
+ WEBHOOK_CREATE = <<~GQL.freeze
112
+ mutation WebhookCreate($input: WebhookCreateInput!) {
113
+ webhookCreate(input: $input) {
114
+ success
115
+ webhook {
116
+ id
117
+ }
118
+ }
119
+ }
120
+ GQL
121
+
122
+ # Archive (soft-delete) a Linear issue by id.
123
+ ISSUE_ARCHIVE = <<~GQL.freeze
124
+ mutation IssueArchive($id: String!) {
125
+ issueArchive(id: $id) {
126
+ success
127
+ }
128
+ }
129
+ GQL
130
+
131
+ # Delete (deregister) a Linear webhook by id.
132
+ WEBHOOK_DELETE = <<~GQL.freeze
133
+ mutation WebhookDelete($id: String!) {
134
+ webhookDelete(id: $id) {
135
+ success
136
+ }
137
+ }
138
+ GQL
139
+
140
+ # Delete (deregister) a webhook from Linear.
141
+ # @param id [String] Linear webhook UUID
142
+ # @return [Boolean] success
143
+ def delete_webhook(id)
144
+ data = post!(WEBHOOK_DELETE, { id: id })
145
+ data.dig("webhookDelete", "success") == true
146
+ end
147
+
148
+ # Viewer identity only. Linear's schema exposes no Query field for the OAuth
149
+ # app actor id of the current token (`applicationWithAuthorization { appActor }`
150
+ # is not valid — that field is absent and its type carries no appActor), so
151
+ # app_actor_id stays nil and EchoGuard no-ops. Loops are already prevented
152
+ # independently by InboundApplier (skip_linear_sync on inbound writes,
153
+ # IssueLink dedup on create, content_hash on update).
154
+ VIEWER = <<~GQL.freeze
155
+ query Viewer {
156
+ viewer {
157
+ id
158
+ organization {
159
+ id
160
+ }
161
+ }
162
+ }
163
+ GQL
164
+
165
+ # List the workspace teams the token can see (for the link-a-project picker).
166
+ TEAMS = <<~GQL.freeze
167
+ query Teams {
168
+ teams(first: 250) {
169
+ nodes {
170
+ id
171
+ name
172
+ key
173
+ }
174
+ }
175
+ }
176
+ GQL
177
+
178
+ # List projects with their owning team ids so the picker can scope projects
179
+ # to the chosen team (a Linear project may belong to more than one team).
180
+ PROJECTS = <<~GQL.freeze
181
+ query Projects {
182
+ projects(first: 250) {
183
+ nodes {
184
+ id
185
+ name
186
+ teams {
187
+ nodes { id }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ GQL
193
+
194
+ def initialize(account)
195
+ @account = account
196
+ @endpoint = resolve_endpoint
197
+ end
198
+
199
+ # Create a Linear issue.
200
+ # @return [Hash] with :id and :identifier
201
+ def create_issue(team_id:, title:, description: nil, parent_id: nil,
202
+ project_id: nil, state_id: nil, assignee_id: nil,
203
+ label_ids: [], priority: nil)
204
+ input = { teamId: team_id, title: title }.tap do |h|
205
+ h[:description] = description if description
206
+ h[:parentId] = parent_id if parent_id
207
+ h[:projectId] = project_id if project_id
208
+ h[:stateId] = state_id if state_id
209
+ h[:assigneeId] = assignee_id if assignee_id
210
+ h[:labelIds] = label_ids if label_ids.any?
211
+ h[:priority] = priority if priority
212
+ end
213
+
214
+ data = post!(ISSUE_CREATE, { input: input })
215
+ node = data.dig("issueCreate", "issue")
216
+ symbolize(node)
217
+ end
218
+
219
+ # Update a Linear issue by id.
220
+ # @param id [String] Linear issue UUID
221
+ # @param fields [Hash] keyword args mapping to IssueUpdateInput fields
222
+ # @return [Hash] with :id and :identifier
223
+ def update_issue(id, **fields)
224
+ input = camelize_keys(fields)
225
+ data = post!(ISSUE_UPDATE, { id: id, input: input })
226
+ node = data.dig("issueUpdate", "issue")
227
+ symbolize(node)
228
+ end
229
+
230
+ # Create a Linear project.
231
+ # @return [Hash] with :id
232
+ def create_project(name:, team_ids:)
233
+ input = { name: name, teamIds: team_ids }
234
+ data = post!(PROJECT_CREATE, { input: input })
235
+ node = data.dig("projectCreate", "project")
236
+ symbolize(node)
237
+ end
238
+
239
+ # Update a Linear project by id.
240
+ # @param id [String] Linear project UUID
241
+ # @param fields [Hash] keyword args mapping to ProjectUpdateInput fields
242
+ # @return [Hash] with :id
243
+ def update_project(id, **fields)
244
+ input = camelize_keys(fields)
245
+ data = post!(PROJECT_UPDATE, { id: id, input: input })
246
+ node = data.dig("projectUpdate", "project")
247
+ symbolize(node)
248
+ end
249
+
250
+ # Archive a Linear issue (soft-delete).
251
+ # @param id [String] Linear issue UUID
252
+ # @return [Boolean] success
253
+ def archive_issue(id)
254
+ data = post!(ISSUE_ARCHIVE, { id: id })
255
+ data.dig("issueArchive", "success") == true
256
+ end
257
+
258
+ # Create a comment on a Linear issue.
259
+ # @return [Hash] with :id
260
+ def create_comment(issue_id:, body:)
261
+ input = { issueId: issue_id, body: body }
262
+ data = post!(COMMENT_CREATE, { input: input })
263
+ node = data.dig("commentCreate", "comment")
264
+ symbolize(node)
265
+ end
266
+
267
+ # Edit an existing Linear comment's body.
268
+ # @return [Hash] with :id
269
+ def update_comment(id:, body:)
270
+ data = post!(COMMENT_UPDATE, { id: id, input: { body: body } })
271
+ node = data.dig("commentUpdate", "comment")
272
+ symbolize(node)
273
+ end
274
+
275
+ # Delete a Linear comment.
276
+ # @return [Boolean] success
277
+ def delete_comment(id)
278
+ data = post!(COMMENT_DELETE, { id: id })
279
+ data.dig("commentDelete", "success") == true
280
+ end
281
+
282
+ # Fetch the authenticated viewer identity.
283
+ # app_actor_id is intentionally nil (no Linear query exposes it for the
284
+ # current token); EchoGuard degrades to a no-op — see VIEWER above.
285
+ # @return [Hash] with :user_id, :app_actor_id, :organization_id
286
+ def viewer_and_app_actor
287
+ data = post!(VIEWER, {})
288
+ {
289
+ user_id: data.dig("viewer", "id"),
290
+ organization_id: data.dig("viewer", "organization", "id"),
291
+ app_actor_id: nil
292
+ }
293
+ end
294
+
295
+ # List workspace teams for the link picker.
296
+ # @return [Array<Hash>] each with :id, :name, :key
297
+ def list_teams
298
+ data = post!(TEAMS, {})
299
+ nodes = data.dig("teams", "nodes") || []
300
+ nodes.map { |n| symbolize(n) }
301
+ end
302
+
303
+ # List projects for the link picker, each with the ids of its owning teams
304
+ # so the UI can scope projects to the selected team.
305
+ # @return [Array<Hash>] each with :id, :name, :team_ids
306
+ def list_projects
307
+ data = post!(PROJECTS, {})
308
+ nodes = data.dig("projects", "nodes") || []
309
+ nodes.map do |n|
310
+ {
311
+ id: n["id"],
312
+ name: n["name"],
313
+ team_ids: (n.dig("teams", "nodes") || []).map { |t| t["id"] }
314
+ }
315
+ end
316
+ end
317
+
318
+ # Register a webhook with Linear.
319
+ # WebhookCreateInput fields: url, secret, teamId, resourceTypes
320
+ # @return [Hash] with :id
321
+ def register_webhook(url:, secret:, team_id:, resource_types:)
322
+ input = { url: url, secret: secret, teamId: team_id, resourceTypes: resource_types }
323
+ data = post!(WEBHOOK_CREATE, { input: input })
324
+ node = data.dig("webhookCreate", "webhook")
325
+ symbolize(node)
326
+ end
327
+
328
+ private
329
+
330
+ attr_reader :account, :endpoint
331
+
332
+ # Execute a GraphQL operation and return the `data` hash.
333
+ # Raises Client::Error if the response contains a top-level `errors` key.
334
+ def post!(query, variables)
335
+ uri = URI.parse(endpoint)
336
+ http = Net::HTTP.new(uri.host, uri.port)
337
+ http.use_ssl = uri.scheme == "https"
338
+ http.open_timeout = 10
339
+ http.read_timeout = 30
340
+
341
+ request = Net::HTTP::Post.new(uri.path.presence || "/")
342
+ request["Content-Type"] = "application/json"
343
+ request["Authorization"] = "Bearer #{fresh_access_token}"
344
+ request.body = { query: query, variables: variables }.to_json
345
+
346
+ response =
347
+ begin
348
+ http.request(request)
349
+ rescue SocketError, SystemCallError, Timeout::Error, IOError,
350
+ OpenSSL::SSL::SSLError => e
351
+ # Transport-layer failures (connection refused/reset, DNS, TLS,
352
+ # open/read timeouts) raise before any GraphQL response exists, so they
353
+ # bypass the parsed-`errors` path below. Wrap them in Error so the
354
+ # outbound jobs' `retry_on Client::Error` treats a transient
355
+ # Linear/network outage as retryable instead of dropping the pending
356
+ # sync/comment/archive on the first failure.
357
+ raise Error, "Linear transport error: #{e.class}: #{e.message}"
358
+ end
359
+
360
+ parsed = begin
361
+ JSON.parse(response.body)
362
+ rescue JSON::ParserError
363
+ raise Error, "Linear returned non-JSON response (HTTP #{response.code}): #{response.body.to_s[0, 200]}"
364
+ end
365
+
366
+ if parsed["errors"].present?
367
+ messages = parsed["errors"].map { |e| e["message"] }.join("; ")
368
+ raise Error, "Linear GraphQL error(s): #{messages}"
369
+ end
370
+
371
+ unless response.is_a?(Net::HTTPSuccess)
372
+ raise Error, "Linear HTTP error: #{response.code} #{response.message}"
373
+ end
374
+
375
+ parsed["data"]
376
+ end
377
+
378
+ # Return a non-expired access token, refreshing via the refresh_token grant
379
+ # when the current token is expiring soon.
380
+ #
381
+ # Guards:
382
+ # - no refresh_token present → use current token (nothing to refresh with)
383
+ # - nil token_expires_at → long-lived token, never triggers a refresh
384
+ # (Account#token_expiring_soon? returns false for a nil expiry)
385
+ def fresh_access_token
386
+ if account.refresh_token.present? && account.token_expiring_soon?
387
+ begin
388
+ CollavreLinear::OAuthTokenService.refresh(account)
389
+ rescue CollavreLinear::OAuthTokenService::Error,
390
+ SocketError, SystemCallError, Timeout::Error, IOError,
391
+ OpenSSL::SSL::SSLError => e
392
+ # The refresh runs before the GraphQL request, so a transient failure
393
+ # here (network/TLS/timeout, or a temporary non-2xx from Linear's token
394
+ # endpoint) raises outside post!'s transport wrap and would bypass the
395
+ # outbound jobs' retry_on Client::Error, dropping the pending sync. Map
396
+ # it to Error so a transient outage is retried, not lost.
397
+ raise Error, "Linear token refresh failed: #{e.class}: #{e.message}"
398
+ end
399
+ end
400
+ account.access_token
401
+ end
402
+
403
+ def resolve_endpoint
404
+ if defined?(Collavre::IntegrationSettings::Resolver)
405
+ Collavre::IntegrationSettings::Resolver.get(:linear_api_endpoint).presence
406
+ end || DEFAULT_ENDPOINT
407
+ end
408
+
409
+ # Convert symbol keys to camelCase strings for GraphQL variables.
410
+ # e.g. :team_id => "teamId"
411
+ def camelize_keys(hash)
412
+ hash.transform_keys { |k| camelize(k.to_s) }
413
+ end
414
+
415
+ def camelize(str)
416
+ parts = str.split("_")
417
+ parts[0] + parts[1..].map(&:capitalize).join
418
+ end
419
+
420
+ def symbolize(hash)
421
+ return {} if hash.nil?
422
+ hash.transform_keys(&:to_sym)
423
+ end
424
+ end
425
+ end