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,599 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Task 10 — applies a verified inbound Linear webhook payload to local Collavre
5
+ # state (Creatives, IssueLinks, CommentLinks).
6
+ #
7
+ # `payload` is a PARSED HASH with string keys, exactly as InboundApplyJob hands
8
+ # it over (never a JSON string). Shape (Linear webhook envelope):
9
+ #
10
+ # {
11
+ # "action" => "create" | "update" | "remove",
12
+ # "type" => "Issue" | "Comment" | ...,
13
+ # "data" => { ...entity fields... },
14
+ # "updatedFrom" => { ...changed fields (update only)... },
15
+ # "webhookTimestamp" => <ms epoch>
16
+ # }
17
+ #
18
+ # Loop guard: every Creative mutated here gets `skip_linear_sync = true` set
19
+ # BEFORE save, so the CreativeSyncObserver's after_commit does NOT bounce the
20
+ # change straight back out to Linear (echo prevention, Task 8).
21
+ #
22
+ # Sequence writes: FieldMapper only COMPUTES the sequence integer. We assign it
23
+ # through the closure_tree-managed model (`creative.sequence = value; save!`),
24
+ # never `update_column`/`update_all`, so the model's ordering callbacks run.
25
+ # Core `Collavre::Creative` has no dedicated reorder helper (it relies on
26
+ # `has_closure_tree order: :sequence` with a plain `sequence` column), so the
27
+ # attribute-assign-then-save path is the correct model-level write.
28
+ #
29
+ # Conflict rule: when the local link is `dirty` (has un-synced local edits) the
30
+ # inbound payload is dispositioned against our last-seen remote timestamp
31
+ # (`remote_updated_at`):
32
+ # * remote strictly NEWER than baseline => genuine concurrent remote edit =>
33
+ # flip to `conflict`, post a Main-topic comment, skip the field apply.
34
+ # * remote present but NOT newer (<=) => a stale echo. A genuinely new remote
35
+ # edit always carries updatedAt > baseline, so a not-newer payload on a
36
+ # dirty link can only be our own outbound update webhooking back (or a
37
+ # replayed delivery). Applying it would clobber the newer pending local
38
+ # edit, so we no-op. (Especially reachable today: issue echo dropping is
39
+ # disabled while `app_actor_id` is nil.)
40
+ # * remote or baseline nil => no basis to compare => apply.
41
+ # "Newer" = remote timestamp > link.remote_updated_at, where the remote
42
+ # timestamp is taken from `data["updatedAt"]` (parsed) and falls back to
43
+ # `webhookTimestamp` (ms epoch) when `updatedAt` is absent.
44
+ class InboundApplier
45
+ def initialize(payload)
46
+ @payload = payload || {}
47
+ @action = @payload["action"]
48
+ @type = @payload["type"]
49
+ @data = @payload["data"] || {}
50
+ end
51
+
52
+ def apply!
53
+ case @type
54
+ when "Comment"
55
+ apply_comment!
56
+ when "Issue"
57
+ apply_issue!
58
+ end
59
+ # Other types (e.g. Project — the webhook subscribes to it) are ignored:
60
+ # routing them through apply_issue! would use the project UUID as a
61
+ # linear_issue_id and create a blank Creative in a one-link install.
62
+ end
63
+
64
+ private
65
+
66
+ # -- Issue events ----------------------------------------------------------
67
+
68
+ def apply_issue!
69
+ case @action
70
+ when "create" then create_issue!
71
+ when "update" then update_issue!
72
+ when "remove" then remove_issue!
73
+ end
74
+ end
75
+
76
+ def create_issue!
77
+ linear_issue_id = @data["id"]
78
+ return if linear_issue_id.blank?
79
+ # Idempotency: a create we already have is a no-op.
80
+ return if IssueLink.find_by(linear_issue_id: linear_issue_id)
81
+
82
+ parent_creative = resolve_create_parent
83
+ return unless parent_creative
84
+
85
+ attrs = FieldMapper.issue_to_creative_attrs(@data)
86
+
87
+ # Wrap Creative + IssueLink in ONE transaction so a concurrent duplicate
88
+ # `create` delivery that loses the unique linear_issue_id race also rolls
89
+ # back the just-saved Creative — otherwise the loser leaves an orphan
90
+ # Creative with no IssueLink.
91
+ Collavre::Creative.transaction do
92
+ creative = Collavre::Creative.new(
93
+ description: description_for(attrs),
94
+ user: actor_user,
95
+ parent: parent_creative
96
+ )
97
+ creative.skip_linear_sync = true
98
+ merge_linear_data!(creative, attrs[:data_linear])
99
+ creative.sequence = attrs[:sequence]
100
+ creative.save!
101
+
102
+ IssueLink.create!(
103
+ creative: creative,
104
+ project_link: project_link,
105
+ linear_issue_id: linear_issue_id,
106
+ parent_issue_id: @data["parentId"],
107
+ remote_updated_at: remote_updated_at,
108
+ sync_state: :synced
109
+ )
110
+
111
+ # Out-of-order delivery: children whose `create` arrived before this
112
+ # parent were attached to the project root (resolve_create_parent's
113
+ # fallback) but recorded parent_issue_id. Now that the parent Creative
114
+ # exists, reparent them so the tree isn't permanently flattened.
115
+ reparent_pending_children!(linear_issue_id, creative)
116
+ end
117
+ rescue ActiveRecord::RecordNotUnique
118
+ # TOCTOU: a concurrent webhook already committed the IssueLink for this
119
+ # linear_issue_id (DB unique index). The enclosing transaction rolled back
120
+ # the Creative we started, so no orphan remains. Treat as already-applied.
121
+ nil
122
+ rescue ActiveRecord::RecordInvalid => e
123
+ # Same race, but the duplicate was already visible to the model-level
124
+ # uniqueness validation. Only swallow the linear_issue_id collision; any
125
+ # other validation failure must still surface.
126
+ raise unless duplicate_issue_link_error?(e)
127
+
128
+ nil
129
+ end
130
+
131
+ # True when the RecordInvalid is exactly the IssueLink linear_issue_id
132
+ # uniqueness collision (the duplicate-create race), not some other invalid.
133
+ def duplicate_issue_link_error?(error)
134
+ record = error.record
135
+ record.is_a?(IssueLink) &&
136
+ record.errors.of_kind?(:linear_issue_id, :taken)
137
+ end
138
+
139
+ def update_issue!
140
+ link = IssueLink.find_by(linear_issue_id: @data["id"])
141
+ # Out-of-order delivery: an `update` can be processed before the issue's
142
+ # `create` webhook has committed its IssueLink (separate InboundApplyJobs,
143
+ # multiple workers). Dropping it loses the edit permanently — the later
144
+ # create only carries the original creation-time payload. The update `data`
145
+ # is the full current entity, so upsert it as a create: the real create is
146
+ # then idempotent, and a projectless/foreign issue still no-ops via
147
+ # resolve_create_parent's proven-membership guard.
148
+ return create_issue! unless link
149
+
150
+ # Lock order MUST match the outbound path: OutboundSyncJob locks the
151
+ # Creative row, then CreativeExporter#update_issue! locks the IssueLink.
152
+ # Taking them in the opposite order (link then creative) lets a concurrent
153
+ # inbound apply and outbound sync for the same creative deadlock on
154
+ # PostgreSQL, and this job has no deadlock retry. Lock Creative, then link.
155
+ creative = link.creative
156
+ creative.with_lock do
157
+ link.lock!
158
+
159
+ # Already conflicted: halt until a human resolves (mirrors the outbound
160
+ # exporter's `return if issue_link.conflict?`). conflict?(link) below only
161
+ # trips for :dirty links, so without this a later remote update would
162
+ # overwrite the local edits that caused the conflict and silently flip the
163
+ # link back to :synced.
164
+ return if link.conflict?
165
+
166
+ case inbound_disposition(link)
167
+ when :conflict
168
+ mark_conflict!(link)
169
+ return
170
+ when :stale
171
+ # Stale echo of an already-seen (or our own) remote state; applying it
172
+ # would overwrite the newer pending local edit. Leave the link dirty so
173
+ # the pending outbound push still carries the local edit to Linear.
174
+ return
175
+ end
176
+
177
+ # Cross-project move: the payload places the issue in a DIFFERENT project
178
+ # than the link records. It cannot be auto-applied — the Creative lives
179
+ # under the old ProjectLink root, and re-homing it (together with its
180
+ # linked sub-issues, which move with it in Linear) under the new root is
181
+ # ambiguous. Applying + marking :synced would silently freeze the wrong
182
+ # mapping (link.project_link stays the old project), so future outbound
183
+ # syncs push against the wrong account. Surface it for a human instead.
184
+ # (parentId-only moves within the same project are handled by
185
+ # apply_parent_change!.) Fires only when the payload explicitly names a
186
+ # project that differs — a same-project update carries the unchanged id.
187
+ payload_project_id = @data["projectId"] || @data.dig("project", "id")
188
+ if payload_project_id.present? &&
189
+ payload_project_id != link.project_link.linear_project_id
190
+ mark_conflict!(link)
191
+ return
192
+ end
193
+
194
+ attrs = FieldMapper.issue_to_creative_attrs(@data)
195
+ changed = changed_keys
196
+
197
+ creative.skip_linear_sync = true
198
+
199
+ # Apply only fields that actually changed (per updatedFrom) when the hint
200
+ # is present; otherwise apply the full mapped set. The creative value
201
+ # tracks the issue TITLE only — a description-only Linear edit must not
202
+ # touch it (the description is ignored).
203
+ if changed.nil? || changed.include?("title")
204
+ creative.description = description_for(attrs)
205
+ end
206
+ if changed.nil? || changed.include?("priority")
207
+ creative.sequence = attrs[:sequence]
208
+ end
209
+ merge_linear_data!(creative, attrs[:data_linear])
210
+
211
+ # Reparent: when the payload's Linear parent differs from what the link
212
+ # last recorded, move the Creative under the corresponding linked parent
213
+ # and track the new parent_issue_id. Without this the tree stays under the
214
+ # old parent AND the content hash below (which folds in the parent's
215
+ # linear_issue_id) would record the OLD parent as synced, hiding the
216
+ # divergence from later outbound syncs. Assigned before save! so the
217
+ # skip_linear_sync guard covers the reparent (no echo back to Linear).
218
+ new_parent_issue_id = apply_parent_change!(creative, link)
219
+
220
+ creative.save!
221
+
222
+ # Advance the outbound content hash to the newly-applied state. Without
223
+ # this the link still holds the OLD outbound hash, so a later local edit
224
+ # back to that old state would make CreativeExporter#update_issue! skip
225
+ # the push, leaving Linear stale. Recompute with the exact same hashing
226
+ # the exporter uses. reload picks up the persisted, closure_tree-managed
227
+ # sequence so inbound + outbound hashes agree.
228
+ link.update!(
229
+ remote_updated_at: remote_updated_at || link.remote_updated_at,
230
+ parent_issue_id: new_parent_issue_id,
231
+ content_hash: CreativeExporter.content_hash_for(creative.reload),
232
+ sync_state: :synced
233
+ )
234
+ end
235
+ end
236
+
237
+ # Reparent the Creative when the inbound Linear parent differs from what the
238
+ # link last recorded. Returns the parent_issue_id that should be persisted on
239
+ # the link (the new one when moved, the unchanged one otherwise).
240
+ #
241
+ # Guard: only move the Creative under a parent that is itself a linked issue.
242
+ # For a not-yet-linked parent (its create webhook hasn't arrived) we still
243
+ # record the new parent_issue_id so reparent_pending_children! can move the
244
+ # Creative once the parent is created — we just don't move it to a bogus node
245
+ # now. Clearing parentId (moved to project root) moves the Creative under the
246
+ # ProjectLink root Creative, mirroring create.
247
+ def apply_parent_change!(creative, link)
248
+ # Only act when the payload actually reports a parent change. When
249
+ # updatedFrom is present but omits parentId, the parent did not change.
250
+ changed = changed_keys
251
+ return link.parent_issue_id if changed && !changed.include?("parentId")
252
+
253
+ new_parent_issue_id = @data["parentId"].presence
254
+ return link.parent_issue_id if new_parent_issue_id == link.parent_issue_id
255
+
256
+ new_parent = resolve_reparent_target(new_parent_issue_id)
257
+ unless new_parent
258
+ # The move targets a real Linear parent whose `create` webhook hasn't
259
+ # been applied yet (out-of-order delivery). Record the NEW parent_issue_id
260
+ # WITHOUT moving the tree: reparent_pending_children! keys on
261
+ # parent_issue_id, so once the parent Creative is created it finds and
262
+ # moves this child. Returning the OLD id would strand it under the old
263
+ # parent forever (the create repair could never match it). A cleared
264
+ # parentId (moved to project root) always resolves, so this only covers a
265
+ # still-unknown, non-blank parent.
266
+ return new_parent_issue_id if new_parent_issue_id.present?
267
+
268
+ return link.parent_issue_id
269
+ end
270
+ return link.parent_issue_id if new_parent.id == creative.parent_id
271
+
272
+ creative.parent = new_parent
273
+ new_parent_issue_id
274
+ end
275
+
276
+ # Resolve the Creative to reparent under for an inbound parent change.
277
+ # - a linked issue id -> that issue's Creative
278
+ # - a blank/absent parentId (moved to project root) -> the ProjectLink root
279
+ # - an unknown/unlinked issue id -> nil (caller leaves as-is)
280
+ def resolve_reparent_target(parent_issue_id)
281
+ if parent_issue_id.present?
282
+ IssueLink.find_by(linear_issue_id: parent_issue_id)&.creative
283
+ else
284
+ project_link&.creative
285
+ end
286
+ end
287
+
288
+ # Decision B6: NO destructive reparent of children. Mark an archive flag and
289
+ # note it on the link; never destroy the Creative or move its children.
290
+ def remove_issue!
291
+ link = IssueLink.find_by(linear_issue_id: @data["id"])
292
+ return unless link
293
+
294
+ # Same lock order as update_issue! / the outbound path: Creative then link.
295
+ creative = link.creative
296
+ creative.with_lock do
297
+ link.lock!
298
+ data = (creative.data || {}).deep_dup
299
+ data["linear"] ||= {}
300
+ data["linear"]["archived"] = true
301
+ creative.skip_linear_sync = true
302
+ creative.data = data
303
+ creative.save!
304
+
305
+ link.update!(sync_state: :synced)
306
+ end
307
+ end
308
+
309
+ # -- Comment events --------------------------------------------------------
310
+
311
+ def apply_comment!
312
+ linear_comment_id = @data["id"]
313
+ return if linear_comment_id.blank?
314
+
315
+ # A Linear `remove` must delete the mirrored comment, never fall through to
316
+ # the upsert path — otherwise it would overwrite/blank the local comment or
317
+ # create an empty one. Handled before issue-link resolution since a removal
318
+ # only needs the CommentLink mapping.
319
+ return remove_comment!(linear_comment_id) if @action == "remove"
320
+
321
+ issue_link = resolve_comment_issue_link
322
+ # The sequential linear_inbound queue applies webhooks in receipt order, so
323
+ # a comment normally lands after its issue's create. If the issue is still
324
+ # unlinked here, either Linear delivered the comment ahead of the create at
325
+ # the network level (a rare residual the in-order queue can't fully close)
326
+ # or the comment belongs to an unlinked issue — in both cases we have no
327
+ # issue to attach to, so drop rather than guess.
328
+ return unless issue_link
329
+
330
+ body = @data["body"].to_s
331
+ comment_link = CommentLink.find_by(linear_comment_id: linear_comment_id)
332
+
333
+ if comment_link
334
+ # Serialize against OutboundCommentUpdateJob, which holds this CommentLink's
335
+ # lock across its Linear round-trip and only advances remote_updated_at
336
+ # afterwards. Reading the baseline without the lock can observe the pre-update
337
+ # value mid-flight, so the echo of our own outbound edit (a strictly-newer
338
+ # updatedAt) is misclassified as a genuine Linear edit and clobbers the local
339
+ # body with the outbound (author-prefixed) form. Lock + reload so the baseline
340
+ # we compare against is the committed one, mirroring the issue apply path.
341
+ comment_link.with_lock do
342
+ comment = Collavre::Comment.find_by(id: comment_link.comment_id)
343
+ # Apply genuine Linear-side edits; skip our own echoes. We compare the
344
+ # webhook's updatedAt against the version we last synced (stored on the
345
+ # link) rather than the mutable local body: a user may edit A->B locally
346
+ # before Linear echoes the older A, and a body comparison would then treat
347
+ # that stale echo as a real edit and clobber B. A strictly-newer remote
348
+ # timestamp is the only thing that means a real Linear-side change.
349
+ if comment && genuine_comment_edit?(comment_link)
350
+ # skip_linear_sync so this Linear-originated edit does not echo back out
351
+ # as an outbound update (which would re-wrap the author-name prefix).
352
+ comment.skip_linear_sync = true
353
+ comment.update!(content: body)
354
+ comment_link.update!(remote_updated_at: remote_updated_at) if remote_updated_at
355
+ end
356
+ end
357
+ else
358
+ create_mirrored_comment!(issue_link, linear_comment_id, body)
359
+ end
360
+ end
361
+
362
+ # True when this comment webhook is a real Linear-side edit rather than our
363
+ # own (possibly stale) echo. "Real" = a remote updatedAt strictly newer than
364
+ # the version we last synced. A missing baseline or remote timestamp falls
365
+ # through to applying, mirroring how issue conflict detection treats an absent
366
+ # baseline (we cannot prove staleness, so we do not silently drop the edit).
367
+ def genuine_comment_edit?(comment_link)
368
+ remote = remote_updated_at
369
+ baseline = comment_link.remote_updated_at
370
+ return true unless remote && baseline
371
+
372
+ remote > baseline
373
+ end
374
+
375
+ # Create the mirrored Collavre comment AND its CommentLink atomically. A
376
+ # concurrent duplicate delivery can pass the CommentLink.find_by check above
377
+ # and reach here twice; the loser hits the unique linear_comment_id index.
378
+ # Wrapping both in one transaction rolls back the just-created Comment on that
379
+ # collision, so no orphan (unlinked) duplicate comment is left behind.
380
+ def create_mirrored_comment!(issue_link, linear_comment_id, body)
381
+ author = user_matching_actor_email
382
+ # A Linear author with no matching Collavre user becomes a SYSTEM comment
383
+ # (user: nil), with the author's name prefixed into the body exactly as the
384
+ # outbound Collavre->Linear direction does. A matched author is attributed
385
+ # directly and needs no prefix.
386
+ content = author ? body : CommentFormatter.prefixed_body(@payload.dig("actor", "name"), body)
387
+ Collavre::Comment.transaction do
388
+ comment = issue_link.creative.comments.new(
389
+ content: content,
390
+ user: author,
391
+ skip_dispatch: true
392
+ )
393
+ # Suppress the outbound echo: this comment came FROM Linear, so the
394
+ # CommentSyncObserver must not post it straight back as a new comment.
395
+ comment.skip_linear_sync = true
396
+ comment.save!
397
+ CommentLink.create!(
398
+ comment_id: comment.id,
399
+ linear_comment_id: linear_comment_id,
400
+ issue_link: issue_link,
401
+ remote_updated_at: remote_updated_at
402
+ )
403
+ end
404
+ rescue ActiveRecord::RecordNotUnique
405
+ # DB unique index caught the duplicate; the transaction rolled back the
406
+ # Comment. Already-applied — treat as a no-op.
407
+ nil
408
+ rescue ActiveRecord::RecordInvalid => e
409
+ # Model-level uniqueness saw the duplicate first. Only swallow the
410
+ # linear_comment_id collision; any other validation failure must surface.
411
+ raise unless duplicate_comment_link_error?(e)
412
+
413
+ nil
414
+ end
415
+
416
+ # True when the RecordInvalid is exactly the CommentLink linear_comment_id
417
+ # uniqueness collision (the duplicate-delivery race), not another invalid.
418
+ def duplicate_comment_link_error?(error)
419
+ record = error.record
420
+ record.is_a?(CommentLink) &&
421
+ record.errors.of_kind?(:linear_comment_id, :taken)
422
+ end
423
+
424
+ # Delete the locally-mirrored comment when Linear removes it. No-op when the
425
+ # comment was never linked, so a stray remove event can't create a blank
426
+ # comment.
427
+ def remove_comment!(linear_comment_id)
428
+ comment_link = CommentLink.find_by(linear_comment_id: linear_comment_id)
429
+ return unless comment_link
430
+
431
+ comment = Collavre::Comment.find_by(id: comment_link.comment_id)
432
+ if comment
433
+ # skip_linear_sync so this Linear-originated removal does not echo back out
434
+ # as an outbound delete of the comment Linear already deleted.
435
+ comment.skip_linear_sync = true
436
+ comment.destroy
437
+ end
438
+ comment_link.destroy
439
+ end
440
+
441
+ # -- Parent / link resolution ---------------------------------------------
442
+
443
+ # Reparent any already-linked children that reference this newly-created
444
+ # parent issue but were attached elsewhere (project root) because the
445
+ # parent's create webhook hadn't arrived yet. Skips children already nested
446
+ # correctly. skip_linear_sync so the reparent doesn't echo back out.
447
+ def reparent_pending_children!(parent_linear_issue_id, parent_creative)
448
+ IssueLink.where(parent_issue_id: parent_linear_issue_id).find_each do |child_link|
449
+ child = child_link.creative
450
+ next if child.nil? || child.parent_id == parent_creative.id
451
+
452
+ child.skip_linear_sync = true
453
+ child.update!(parent: parent_creative)
454
+ end
455
+ end
456
+
457
+ # For create: nest under the parent issue's Creative if that parent is linked,
458
+ # otherwise fall back to the ProjectLink's root Creative.
459
+ def resolve_create_parent
460
+ if (parent_issue_id = @data["parentId"]).present?
461
+ parent_link = IssueLink.find_by(linear_issue_id: parent_issue_id)
462
+ return parent_link.creative if parent_link
463
+ end
464
+ project_link&.creative
465
+ end
466
+
467
+ # Resolve the governing ProjectLink for this payload. Membership must be
468
+ # PROVEN — Linear webhooks are team-scoped, so a projectless issue (backlog,
469
+ # or a different project in the same team) must not be adopted into a linked
470
+ # project's subtree. Preference order:
471
+ # 1. explicit projectId / project.id on the entity,
472
+ # 2. the parent issue's ProjectLink (sub-issue whose parent is linked).
473
+ # There is deliberately NO sole-ProjectLink fallback: a payload that names
474
+ # neither a project nor a linked parent cannot prove it belongs to any linked
475
+ # project, so it resolves to nil and the caller no-ops the import.
476
+ def project_link
477
+ return @project_link if defined?(@project_link)
478
+
479
+ @project_link =
480
+ if (pid = @data["projectId"] || @data.dig("project", "id")).present?
481
+ ProjectLink.find_by(linear_project_id: pid)
482
+ elsif (parent_issue_id = @data["parentId"]).present? &&
483
+ (pl = IssueLink.find_by(linear_issue_id: parent_issue_id))
484
+ pl.project_link
485
+ end
486
+ end
487
+
488
+ def resolve_comment_issue_link
489
+ issue_id = @data.dig("issue", "id") || @data["issueId"]
490
+ return unless issue_id
491
+
492
+ IssueLink.find_by(linear_issue_id: issue_id)
493
+ end
494
+
495
+ # -- Conflict detection ----------------------------------------------------
496
+
497
+ # Classify how an inbound update should be treated relative to local state:
498
+ # :apply — safe to apply (clean link, or dirty with no baseline to
499
+ # compare against).
500
+ # :conflict — dirty link AND remote strictly newer than baseline (genuine
501
+ # concurrent remote edit; human resolves).
502
+ # :stale — dirty link AND remote present but NOT newer than baseline. A
503
+ # genuinely new remote edit always has updatedAt > baseline, so
504
+ # this can only be a stale/own echo; no-op rather than clobber
505
+ # the pending local edit.
506
+ def inbound_disposition(link)
507
+ return :apply unless link.dirty?
508
+
509
+ remote = remote_updated_at
510
+ baseline = link.remote_updated_at
511
+ # No basis to compare (fresh dirty link or missing remote stamp) — apply.
512
+ return :apply unless remote && baseline
513
+
514
+ remote > baseline ? :conflict : :stale
515
+ end
516
+
517
+ def mark_conflict!(link)
518
+ link.update!(sync_state: :conflict)
519
+ post_conflict_comment(link.creative)
520
+ end
521
+
522
+ def post_conflict_comment(creative)
523
+ creative.comments.create!(
524
+ content: conflict_message,
525
+ user: actor_user,
526
+ topic: creative.main_topic(fallback_user: actor_user),
527
+ skip_dispatch: true
528
+ )
529
+ rescue StandardError => e
530
+ # A notification failure must never mask the (correct) skip-apply behavior.
531
+ Rails.logger.error(
532
+ "[CollavreLinear::InboundApplier] failed to post conflict comment: " \
533
+ "#{e.class}: #{e.message}"
534
+ )
535
+ end
536
+
537
+ def conflict_message
538
+ I18n.t("collavre_linear.integration.conflict_notice")
539
+ end
540
+
541
+ # -- Field helpers ---------------------------------------------------------
542
+
543
+ # Collavre::Creative has no title column — the canonical text lives in
544
+ # `description` (title is derived from it via creative_snippet). The Linear
545
+ # issue TITLE is that canonical value; the Linear issue description is
546
+ # intentionally ignored for now (product decision).
547
+ def description_for(attrs)
548
+ attrs[:title].presence || ""
549
+ end
550
+
551
+ def merge_linear_data!(creative, data_linear)
552
+ return if data_linear.blank?
553
+
554
+ data = (creative.data || {}).deep_dup
555
+ data["linear"] ||= {}
556
+ data["linear"].merge!(data_linear.deep_stringify_keys)
557
+ creative.data = data
558
+ end
559
+
560
+ # `updatedFrom` (update events) lists the fields that changed. Returns nil
561
+ # when absent so callers apply the full mapped set.
562
+ def changed_keys
563
+ uf = @payload["updatedFrom"]
564
+ return nil unless uf.is_a?(Hash)
565
+
566
+ uf.keys
567
+ end
568
+
569
+ def remote_updated_at
570
+ if (ts = @data["updatedAt"]).present?
571
+ return Time.zone.parse(ts.to_s) rescue nil
572
+ end
573
+ if (wt = @payload["webhookTimestamp"]).present?
574
+ # webhookTimestamp is milliseconds since epoch.
575
+ return Time.zone.at(wt.to_i / 1000.0)
576
+ end
577
+ nil
578
+ end
579
+
580
+ def actor_user
581
+ @actor_user ||= project_link&.account&.user
582
+ end
583
+
584
+ # Author for an inbound Linear COMMENT. A human writing in Linear should show
585
+ # up as themselves in Collavre when their `actor.email` matches a Collavre
586
+ # user. When it does not (Linear-only author, or no email), the caller stores
587
+ # a system comment (nil user) with a "[name]:" prefix instead — never the
588
+ # shared connecting account. Our own outbound comments never reach here: the
589
+ # controller drops app-actor events via EchoGuard before enqueuing.
590
+ def user_matching_actor_email
591
+ email = @payload.dig("actor", "email")
592
+ return nil if email.blank?
593
+
594
+ # User#email is normalized to strip.downcase, so match the same way for a
595
+ # case- and whitespace-insensitive lookup.
596
+ Collavre.user_class.find_by(email: email.strip.downcase)
597
+ end
598
+ end
599
+ end