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.
- checksums.yaml +7 -0
- data/app/controllers/collavre_linear/application_controller.rb +21 -0
- data/app/controllers/collavre_linear/auth_controller.rb +135 -0
- data/app/controllers/collavre_linear/creatives/integrations_controller.rb +271 -0
- data/app/controllers/collavre_linear/webhooks_controller.rb +187 -0
- data/app/javascript/collavre_linear.js +199 -0
- data/app/jobs/collavre_linear/inbound_apply_job.rb +36 -0
- data/app/jobs/collavre_linear/outbound_archive_job.rb +29 -0
- data/app/jobs/collavre_linear/outbound_comment_delete_job.rb +41 -0
- data/app/jobs/collavre_linear/outbound_comment_sync_job.rb +112 -0
- data/app/jobs/collavre_linear/outbound_comment_update_job.rb +55 -0
- data/app/jobs/collavre_linear/outbound_sync_job.rb +39 -0
- data/app/models/collavre_linear/account.rb +29 -0
- data/app/models/collavre_linear/application_record.rb +7 -0
- data/app/models/collavre_linear/comment_link.rb +12 -0
- data/app/models/collavre_linear/issue_link.rb +20 -0
- data/app/models/collavre_linear/project_link.rb +76 -0
- data/app/observers/collavre_linear/comment_sync_observer.rb +97 -0
- data/app/observers/collavre_linear/creative_sync_observer.rb +162 -0
- data/app/services/collavre_linear/client.rb +425 -0
- data/app/services/collavre_linear/comment_formatter.rb +34 -0
- data/app/services/collavre_linear/comment_syncability.rb +33 -0
- data/app/services/collavre_linear/creative_exporter.rb +352 -0
- data/app/services/collavre_linear/echo_guard.rb +36 -0
- data/app/services/collavre_linear/field_mapper.rb +119 -0
- data/app/services/collavre_linear/inbound_applier.rb +599 -0
- data/app/services/collavre_linear/o_auth_token_service.rb +163 -0
- data/app/views/collavre_linear/auth/setup.html.erb +63 -0
- data/app/views/collavre_linear/integrations/_modal.html.erb +200 -0
- data/config/initializers/integration_settings.rb +8 -0
- data/config/locales/en.yml +49 -0
- data/config/locales/ko.yml +49 -0
- data/config/routes.rb +22 -0
- data/db/migrate/20260701000000_create_linear_accounts.rb +20 -0
- data/db/migrate/20260701000001_create_collavre_linear_project_links.rb +18 -0
- data/db/migrate/20260701000002_create_collavre_linear_issue_links.rb +22 -0
- data/db/migrate/20260701000003_create_collavre_linear_comment_links.rb +17 -0
- data/db/migrate/20260701000004_add_last_outbound_at_to_linear_links.rb +6 -0
- data/db/migrate/20260701000005_add_webhook_id_to_linear_project_links.rb +5 -0
- data/db/migrate/20260701000006_add_lookup_indexes_to_linear_project_links.rb +17 -0
- data/db/migrate/20260701000007_enforce_one_project_link_per_creative.rb +19 -0
- data/db/migrate/20260701000008_allow_null_webhook_secret_on_linear_project_links.rb +8 -0
- data/lib/collavre_linear/engine.rb +122 -0
- data/lib/collavre_linear/version.rb +3 -0
- data/lib/collavre_linear.rb +5 -0
- 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
|