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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Builds the Linear-facing body for a Collavre comment.
5
+ #
6
+ # Every Collavre chat participant reaches Linear through one shared app actor,
7
+ # so their comments would otherwise be indistinguishable on the Linear side.
8
+ # Prefixing the author's display name ("[정순오]: ...") preserves attribution.
9
+ #
10
+ # The same formatter drives the inbound echo guard: when Linear webhooks our
11
+ # own comment back, the incoming body equals this prefixed form, so the
12
+ # applier can recognise the echo and leave the canonical local comment intact.
13
+ #
14
+ # The brackets are backslash-escaped because Linear renders comments as
15
+ # Markdown: an unescaped "[name]: word" is a link reference definition (label +
16
+ # destination) that Linear swallows into an empty string when the content is a
17
+ # single token. Escaping keeps the whole line literal regardless of word count.
18
+ module CommentFormatter
19
+ module_function
20
+
21
+ def outbound_body(comment)
22
+ prefixed_body(comment.user&.display_name, comment.content.to_s)
23
+ end
24
+
25
+ # Prefix `content` with an escaped "[author]: " attribution, or return it
26
+ # unchanged when `author` is blank. Single source of the prefix format so the
27
+ # outbound Collavre->Linear body and the inbound system-comment fallback (a
28
+ # Linear author with no matching Collavre user) stay byte-identical — the echo
29
+ # guard above relies on the two forms matching.
30
+ def prefixed_body(author, content)
31
+ author.present? ? "\\[#{author}\\]: #{content}" : content
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Single source of truth for "may this Collavre comment be mirrored to Linear?".
5
+ #
6
+ # Shared by CommentSyncObserver (deciding whether to enqueue outbound work) and
7
+ # OutboundCommentUpdateJob (re-checking at run time): a comment can be made
8
+ # private or moved out of the Main topic AFTER an update job is enqueued, and
9
+ # with multiple workers that update could run before the delete the observer
10
+ # enqueues for the same visibility change — leaking the now-hidden body. Both
11
+ # gates share this predicate so they can never drift.
12
+ module CommentSyncability
13
+ module_function
14
+
15
+ # Only mirror a real, still-visible human chat post in the Main topic of a
16
+ # creative linked to a Linear issue. Excludes: inbound echoes; private notes;
17
+ # system/waiting notices (no user); AI agent turns (their comment starts as a
18
+ # "..." streaming placeholder and mutates in place); the placeholder itself;
19
+ # and comments moved out of Main or on unlinked creatives.
20
+ def syncable?(comment)
21
+ return false if comment.skip_linear_sync
22
+ return false if comment.private?
23
+ return false if comment.user_id.nil?
24
+ return false if comment.user&.ai_user?
25
+ return false if comment.content.blank?
26
+ return false if comment.content == ::Collavre::Comment::STREAMING_PLACEHOLDER_CONTENT
27
+ return false unless comment.topic&.name == ::Collavre::Creative::MAIN_TOPIC_NAME
28
+ return false unless comment.creative
29
+
30
+ comment.creative.linear_issue_links.exists?
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module CollavreLinear
6
+ # Syncs a single Collavre::Creative to Linear as an issue.
7
+ #
8
+ # Usage:
9
+ # CollavreLinear::CreativeExporter.new(creative).sync!
10
+ #
11
+ # Resolves the governing ProjectLink from the creative itself or its nearest
12
+ # ancestor. Creates a new Linear issue and IssueLink on first run; updates the
13
+ # existing issue on subsequent runs. Skips the network call when the content
14
+ # hash is unchanged (dirty-tracking). Raises Client::Error on network failure
15
+ # so the caller (OutboundSyncJob) can retry.
16
+ #
17
+ # Project-root mapping: the ProjectLink-root Creative maps to the Linear
18
+ # PROJECT itself, NOT an issue. Its direct children become the project's
19
+ # TOP-LEVEL issues (parentId nil); deeper descendants nest as sub-issues.
20
+ # Exporting the root as an issue would consume the project's single top-level
21
+ # slot and flatten every sibling under it, so sync! no-ops for the root.
22
+ # Inbound mirrors this: a top-level Linear issue is imported as a direct child
23
+ # of the root Creative (see InboundApplier#resolve_create_parent).
24
+ class CreativeExporter
25
+ # Raised when a child must be exported AFTER its parent's Linear issue
26
+ # exists but the parent hasn't been exported yet (independent per-creative
27
+ # jobs can run out of order under multiple queue workers). The job catches
28
+ # this and re-enqueues so the child retries once the parent has landed —
29
+ # without this, the child would be created as a TOP-LEVEL Linear issue and
30
+ # the tree would be permanently flattened on first export.
31
+ class ParentNotExportedError < StandardError; end
32
+
33
+ # Thin adapter so FieldMapper's #title contract is met without touching core.
34
+ # Collavre::Creative has no :title column — we derive it from creative_snippet
35
+ # (plain-text truncation of the description used everywhere in the app).
36
+ CreativeAdapter = Struct.new(:title, :description, :sequence, :data, keyword_init: true)
37
+
38
+ # Compute the outbound content hash for a Creative's current state. Shared
39
+ # with the inbound applier so it can advance IssueLink#content_hash after
40
+ # applying a remote update, keeping the exporter's dirty-check consistent.
41
+ #
42
+ # The hash folds in the parent's linear_issue_id so that a pure reparent
43
+ # (content fields unchanged) still changes the hash and drives update_issue!,
44
+ # which pushes the new parentId to Linear. Otherwise Collavre and Linear
45
+ # hierarchies would diverge after a move.
46
+ def self.content_hash_for(creative)
47
+ attrs = FieldMapper.creative_to_issue_attrs(
48
+ CreativeAdapter.new(
49
+ title: creative.creative_snippet,
50
+ description: creative.description,
51
+ sequence: creative.sequence,
52
+ data: creative.data
53
+ )
54
+ )
55
+ hash_attrs(attrs, parent_linear_issue_id_for(creative))
56
+ end
57
+
58
+ # Resolve the parent Creative's linked linear_issue_id (or nil when the
59
+ # parent is absent or not itself linked to a Linear issue).
60
+ def self.parent_linear_issue_id_for(creative)
61
+ parent = creative.parent
62
+ return nil unless parent
63
+
64
+ parent.linear_issue_links.first&.linear_issue_id
65
+ end
66
+
67
+ # SHA-256 of the sorted, serialised mapped attrs plus the parent's linear
68
+ # issue id (stable key order). Single source of truth so inbound + outbound
69
+ # and the class/instance paths all agree.
70
+ def self.hash_attrs(attrs, parent_linear_issue_id)
71
+ payload = attrs.merge(_parent_linear_issue_id: parent_linear_issue_id)
72
+ Digest::SHA256.hexdigest(payload.sort.to_h.to_json)
73
+ end
74
+
75
+ def initialize(creative)
76
+ @creative = creative
77
+ end
78
+
79
+ def sync!
80
+ project_link = resolve_project_link
81
+ return unless project_link
82
+
83
+ # The ProjectLink-root Creative IS the project, not an issue — skip it.
84
+ # (Its children export as the project's top-level issues; see class docs.)
85
+ return if project_link.creative_id == @creative.id
86
+
87
+ account = project_link.account
88
+ client = Client.new(account)
89
+ attrs = FieldMapper.creative_to_issue_attrs(adapt(@creative))
90
+ parent_id = parent_linear_issue_id
91
+ hash = compute_content_hash(attrs, parent_id)
92
+
93
+ issue_link = @creative.linear_issue_links.first
94
+
95
+ # Cross-project move: the creative was reparented under a DIFFERENT linked
96
+ # root than the one its existing issue belongs to. resolve_project_link now
97
+ # returns the new project, so updating would push the OLD project's issue
98
+ # with the NEW project's account/client (wrong project) and diverge the tree
99
+ # from Linear. It cannot be auto-applied — re-homing the issue (and its
100
+ # linked sub-issues, which move with it in Linear) into the new project is
101
+ # ambiguous. Halt + surface as conflict (mirrors the inbound cross-project
102
+ # handling); actively migrating/recreating is a product decision. Checked
103
+ # before the parent-export guard so a cross-root move never defers.
104
+ if issue_link && issue_link.project_link_id != project_link.id
105
+ return mark_cross_project_conflict!(issue_link)
106
+ end
107
+
108
+ # Ordering guard for BOTH create and update: if this creative's parent
109
+ # belongs to the exported subtree but has not yet produced its Linear issue,
110
+ # defer (the job re-enqueues on ParentNotExportedError once the parent lands).
111
+ # - create: exporting now would make a top-level issue, flattening the tree.
112
+ # - update: an already-linked creative moved under a NEWLY-created parent
113
+ # can run before the parent's create job. Without waiting we'd send no
114
+ # parentId, persist parent_issue_id for the still-nil parent, and the
115
+ # later parent export would NOT re-enqueue us — leaving the Linear issue
116
+ # under its old parent until a manual resync.
117
+ raise ParentNotExportedError if parent_export_pending?
118
+
119
+ if issue_link.nil?
120
+ create_issue!(client, project_link, attrs, hash, parent_id)
121
+ else
122
+ update_issue!(client, issue_link, attrs, hash, parent_id)
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ # True when this creative's parent is itself part of the exported subtree
129
+ # (it resolves the governing ProjectLink) but has not yet been exported to
130
+ # Linear (no linear_issue_id). The parent MUST land first so this child can
131
+ # be nested under it. The ProjectLink-root creative's own parent is outside
132
+ # the subtree (resolves no link), so the root never defers.
133
+ def parent_export_pending?
134
+ parent = @creative.parent
135
+ return false unless parent
136
+ # Parent is the project itself (its own ProjectLink root). The project
137
+ # never becomes a Linear issue, so this creative is a TOP-LEVEL issue and
138
+ # must NOT wait for a parent issue that will never exist.
139
+ return false if CollavreLinear::ProjectLink.exists?(creative_id: parent.id)
140
+
141
+ # A conflict-frozen parent is NOT a valid nesting target: it was halted
142
+ # mid cross-project move, so its Linear issue may live in a DIFFERENT
143
+ # project than this child resolves to. Adopting its linear_issue_id as the
144
+ # child's parentId would send Linear a cross-project parent (rejected, or
145
+ # the child lands in the wrong project). A brand-new child has no IssueLink,
146
+ # so the cross-project guard in sync! (which needs an existing link) never
147
+ # fires — defer here until the parent's conflict is resolved (the child's
148
+ # job retries; an explicit resync re-homes the parent into this project).
149
+ parent_link = parent.linear_issue_links.first
150
+ return false if parent_link&.linear_issue_id.present? && !parent_link.conflict?
151
+
152
+ # Parent is in the linked subtree only if it (or an ancestor) holds a
153
+ # ProjectLink. If not, the parent is above the linked root — no wait.
154
+ parent.self_and_ancestors.any? do |ancestor|
155
+ CollavreLinear::ProjectLink.exists?(creative_id: ancestor.id)
156
+ end
157
+ end
158
+
159
+ # Wrap a Collavre::Creative in the adapter expected by FieldMapper.
160
+ # :title is derived from creative_snippet (plain-text, max 24 chars).
161
+ def adapt(creative)
162
+ CreativeAdapter.new(
163
+ title: creative.creative_snippet,
164
+ description: creative.description,
165
+ sequence: creative.sequence,
166
+ data: creative.data
167
+ )
168
+ end
169
+
170
+ # Walk self-and-ancestors (nearest first) to find a ProjectLink.
171
+ # closure_tree's `self_and_ancestors` returns [self, parent, grandparent, …].
172
+ def resolve_project_link
173
+ @creative.self_and_ancestors.each do |ancestor|
174
+ link = CollavreLinear::ProjectLink.find_by(creative_id: ancestor.id)
175
+ return link if link
176
+ end
177
+ nil
178
+ end
179
+
180
+ # Find the parent Creative's linear_issue_id, if any.
181
+ def parent_linear_issue_id
182
+ self.class.parent_linear_issue_id_for(@creative)
183
+ end
184
+
185
+ # SHA-256 of the sorted, serialised mapped attrs plus the parent's linear
186
+ # issue id (stable key order). Shared with CreativeExporter.content_hash_for
187
+ # so inbound + outbound agree, including on reparents.
188
+ def compute_content_hash(attrs, parent_id)
189
+ self.class.hash_attrs(attrs, parent_id)
190
+ end
191
+
192
+ def create_issue!(client, project_link, attrs, hash, parent_id)
193
+ response = client.create_issue(
194
+ team_id: project_link.team_id,
195
+ project_id: project_link.linear_project_id,
196
+ parent_id: parent_id,
197
+ **attrs
198
+ )
199
+
200
+ linear_issue_id = response[:id]
201
+
202
+ issue_link = CollavreLinear::IssueLink.create!(
203
+ creative: @creative,
204
+ project_link: project_link,
205
+ linear_issue_id: linear_issue_id,
206
+ parent_issue_id: parent_id,
207
+ content_hash: hash,
208
+ # Baseline the sync clock on LINEAR's updatedAt (not Time.current): the
209
+ # inbound applier compares this against a webhook's Linear-clock
210
+ # updatedAt, so an app-clock baseline would misclassify a genuine remote
211
+ # edit as a stale echo and drop it. Fall back to app clock only if Linear
212
+ # omits it. (Mirrors the comment jobs.)
213
+ remote_updated_at: response[:updatedAt] || Time.current,
214
+ local_version: 1,
215
+ sync_state: :synced
216
+ )
217
+
218
+ EchoGuard.record_outbound(issue_link, linear_issue_id)
219
+ backfill_pending_comments!(@creative)
220
+ issue_link
221
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
222
+ # Self-echo race: the inbound webhook for the issue we JUST created can land
223
+ # in the window between create_issue and IssueLink.create! (app_actor_id is
224
+ # nil, so EchoGuard can't drop it), creating a duplicate Creative that grabs
225
+ # the IssueLink first. Adopt that link onto our original creative and remove
226
+ # the duplicate so the issue isn't double-represented and our creative isn't
227
+ # left unlinked. Any other validation error must still surface.
228
+ raise if e.is_a?(ActiveRecord::RecordInvalid) && !duplicate_issue_link_error?(e)
229
+
230
+ reconcile_self_echo!(linear_issue_id, project_link, hash, parent_id, response[:updatedAt])
231
+ end
232
+
233
+ # True when the RecordInvalid is exactly the IssueLink linear_issue_id
234
+ # uniqueness collision (the self-echo race), not some other invalid record.
235
+ def duplicate_issue_link_error?(error)
236
+ record = error.record
237
+ record.is_a?(CollavreLinear::IssueLink) &&
238
+ record.errors.of_kind?(:linear_issue_id, :taken)
239
+ end
240
+
241
+ def reconcile_self_echo!(linear_issue_id, project_link, hash, parent_id, remote_updated_at = nil)
242
+ existing = CollavreLinear::IssueLink.find_by(linear_issue_id: linear_issue_id)
243
+ return existing unless existing
244
+
245
+ duplicate = existing.creative
246
+ CollavreLinear::IssueLink.transaction do
247
+ existing.update!(
248
+ creative: @creative,
249
+ project_link: project_link,
250
+ parent_issue_id: parent_id,
251
+ content_hash: hash,
252
+ remote_updated_at: remote_updated_at || Time.current,
253
+ local_version: 1,
254
+ sync_state: :synced
255
+ )
256
+
257
+ # Remove the echo duplicate. The link was reassigned above, so this must
258
+ # NOT archive the Linear issue we just created — skip_linear_sync guards
259
+ # the destroy observer, and the creative now holds no IssueLink anyway.
260
+ if duplicate && duplicate.id != @creative.id
261
+ duplicate.skip_linear_sync = true
262
+ duplicate.destroy!
263
+ end
264
+ end
265
+
266
+ EchoGuard.record_outbound(existing, linear_issue_id)
267
+ backfill_pending_comments!(@creative)
268
+ existing
269
+ end
270
+
271
+ # A comment posted in the window before this creative's IssueLink existed was
272
+ # skipped by CommentSyncObserver (syncable? requires a link) with no later
273
+ # backfill, permanently dropping it. Now that the link exists, enqueue those
274
+ # comments. OutboundCommentSyncJob is idempotent (re-checks CommentLink +
275
+ # syncability under lock), so already-synced/non-syncable comments no-op.
276
+ def backfill_pending_comments!(creative)
277
+ creative.comments.find_each do |comment|
278
+ next unless CommentSyncability.syncable?(comment)
279
+
280
+ OutboundCommentSyncJob.perform_later(comment.id)
281
+ end
282
+ end
283
+
284
+ # A cross-project move can't be auto-applied; freeze the link at :conflict so
285
+ # a stale queued job never pushes the wrong project and a human resolves it
286
+ # (via resync after re-linking). Idempotent: already-conflicted links no-op.
287
+ def mark_cross_project_conflict!(issue_link)
288
+ issue_link.with_lock do
289
+ return issue_link if issue_link.conflict?
290
+
291
+ issue_link.update!(sync_state: :conflict)
292
+ end
293
+ issue_link
294
+ end
295
+
296
+ def update_issue!(client, issue_link, attrs, hash, parent_id)
297
+ issue_link.with_lock do
298
+ # Conflict policy (see docs "Conflict handling"): a newer inbound Linear
299
+ # change flipped this link to :conflict. Auto-sync HALTS until an explicit
300
+ # resync resolves it — a stale queued job must never overwrite the remote
301
+ # change we already chose not to clobber.
302
+ return issue_link if issue_link.conflict?
303
+
304
+ # Re-check under lock: a concurrent update with identical content must not
305
+ # trigger a redundant API call. The pre-lock check is gone intentionally
306
+ # to eliminate the TOCTOU window between the dirty-check and the lock
307
+ # acquisition.
308
+ if issue_link.content_hash == hash
309
+ # Content is already in sync with Linear. If the observer marked the
310
+ # link :dirty (e.g. for a metadata-only save that touched no mapped
311
+ # field), reset it now so a subsequent inbound update does not
312
+ # incorrectly trip conflict?. Use update_column to avoid callbacks.
313
+ if issue_link.dirty?
314
+ issue_link.update_column(:sync_state, CollavreLinear::IssueLink.sync_states[:synced])
315
+ end
316
+ return issue_link
317
+ end
318
+
319
+ # Carry parentId on update so a reparent in Collavre moves the issue in
320
+ # Linear too. Only send it when the parent is itself a linked Linear
321
+ # issue (mirrors create_issue!); sending a nil/bogus parentId would error.
322
+ # When the parent is cleared (moved out from under a linked parent to the
323
+ # project root), send an EXPLICIT null: Linear only changes provided
324
+ # fields, so omitting parentId would leave the issue nested under its old
325
+ # parent while we persist parent_issue_id: nil + synced — permanent drift.
326
+ update_fields = attrs
327
+ if parent_id
328
+ update_fields = update_fields.merge(parent_id: parent_id)
329
+ elsif issue_link.parent_issue_id.present?
330
+ update_fields = update_fields.merge(parent_id: nil)
331
+ end
332
+
333
+ response = client.update_issue(issue_link.linear_issue_id, **update_fields)
334
+
335
+ issue_link.update!(
336
+ content_hash: hash,
337
+ parent_issue_id: parent_id,
338
+ # Baseline on Linear's updatedAt so inbound staleness is compared
339
+ # Linear-clock vs Linear-clock (see create_issue!). Preserve the prior
340
+ # baseline if Linear omits it rather than resetting to app clock.
341
+ remote_updated_at: response[:updatedAt] || issue_link.remote_updated_at,
342
+ local_version: issue_link.local_version + 1,
343
+ sync_state: :synced
344
+ )
345
+
346
+ EchoGuard.record_outbound(issue_link, response[:id])
347
+ end
348
+
349
+ issue_link
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Suppresses echo loops that arise when our own webhook events bounce back.
5
+ #
6
+ # PRIMARY (and only active) guard: compare the webhook `actor.id` against
7
+ # `account.app_actor_id`. When `app_actor_id` is nil the check returns false
8
+ # (conservative — no suppression), so the guard is a no-op until the account
9
+ # is fully provisioned.
10
+ #
11
+ # `last_outbound_at` is written by record_outbound for observability and as a
12
+ # hook for future guards, but it is NOT currently read by any suppression
13
+ # logic.
14
+ module EchoGuard
15
+ # Returns true when the webhook payload was triggered by our own app actor.
16
+ # Safe against nil payload, missing keys, and nil app_actor_id — always
17
+ # returns false rather than raising.
18
+ def self.our_event?(account, payload)
19
+ actor_id = payload&.dig("actor", "id")
20
+ return false if actor_id.nil?
21
+
22
+ app_actor = account.app_actor_id
23
+ return false if app_actor.nil?
24
+
25
+ actor_id == app_actor
26
+ end
27
+
28
+ # Stamps `link.last_outbound_at` to the current time for observability.
29
+ # This is NOT an active suppression guard — it is recorded so future logic
30
+ # can consult it without a schema change. `linear_id` is accepted for
31
+ # future in-flight token tracking but is not persisted in this implementation.
32
+ def self.record_outbound(link, _linear_id = nil)
33
+ link.update_column(:last_outbound_at, Time.current)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Pure field translation between Collavre Creative attributes and Linear issue
5
+ # attributes. NO I/O — this class never reads from the DB or makes HTTP calls.
6
+ #
7
+ # == priority <-> sequence mapping (locked product decision)
8
+ #
9
+ # Linear priority is a 5-value enum:
10
+ # 0 = No priority, 1 = Urgent, 2 = High, 3 = Medium, 4 = Low
11
+ #
12
+ # Collavre sequence is a dense, ZERO-BASED total order of siblings (closure_tree):
13
+ # core resequencing (`CreateService#resequence`, `Reorderer#resequence!`) assigns
14
+ # `each_with_index`, so the first sibling is sequence 0. The mapping must honor
15
+ # that base — otherwise the first (top-ranked) sibling would export as "None".
16
+ #
17
+ # Inbound (Linear → Collavre): sequence = (priority == 0 ? 4 : priority - 1)
18
+ # Urgent(1) → 0 (first), Low(4) → 3, None(0) → 4 (after all ranked buckets).
19
+ #
20
+ # Outbound (Collavre → Linear): sequence 0-3 maps to priority 1-4 (0→Urgent);
21
+ # sequence 4 (the "None" sentinel) and nil/unranked → priority 0 (None).
22
+ #
23
+ # Lossy edge: Linear priority is a 5-bucket enum; Collavre sequence is a dense
24
+ # integer total order. Within-bucket ordering is NOT representable in Linear
25
+ # priority — only the bucket (1-4) is preserved. The Task 10 applier must
26
+ # write the computed sequence via closure_tree's reorder path, not a raw column
27
+ # update, because closure_tree maintains sibling order invariants.
28
+ module FieldMapper
29
+ module_function
30
+
31
+ # Outbound: Creative → Linear issue attributes.
32
+ #
33
+ # @param creative [#title, #description, #sequence, #data] — a Creative
34
+ # (or compatible stub). Must NOT expose #progress; the mapper never reads it.
35
+ # @return [Hash] with keys: :title, :description, :priority, and optionally
36
+ # :state_id and :label_ids (omitted when not set).
37
+ def creative_to_issue_attrs(creative)
38
+ attrs = {
39
+ title: creative.title,
40
+ description: creative.description,
41
+ priority: sequence_to_priority(creative.sequence)
42
+ }
43
+
44
+ linear_data = (creative.data || {})["linear"] || {}
45
+
46
+ if (state = linear_data["state"])
47
+ attrs[:state_id] = state["id"]
48
+ end
49
+
50
+ if (labels = linear_data["labels"]) && labels.any?
51
+ attrs[:label_ids] = labels.map { |l| l["id"] }
52
+ end
53
+
54
+ attrs
55
+ end
56
+
57
+ # Inbound: Linear issue payload → Collavre creative attributes.
58
+ #
59
+ # @param issue_payload [Hash] — a raw Linear issue hash (string keys), e.g.
60
+ # from a webhook or API response. Expected keys: "title", "description",
61
+ # "priority", "state", "labels" ({"nodes" => [...]}), "assignee".
62
+ # @return [Hash] with keys: :title, :description, :sequence,
63
+ # :data_linear ({state:, labels:, assignee:}).
64
+ # Never contains :progress.
65
+ def issue_to_creative_attrs(issue_payload)
66
+ priority = issue_payload["priority"].to_i
67
+
68
+ {
69
+ title: issue_payload["title"],
70
+ description: issue_payload["description"],
71
+ sequence: priority_to_sequence(priority),
72
+ data_linear: {
73
+ state: issue_payload["state"],
74
+ labels: normalize_labels(issue_payload["labels"]),
75
+ assignee: issue_payload["assignee"]
76
+ }
77
+ }
78
+ end
79
+
80
+ # -- Private helpers -------------------------------------------------------
81
+
82
+ # Linear delivers `labels` in two shapes: the webhook payload sends a flat
83
+ # Array (`[]` or `[{...}]`), while the GraphQL API nests them under
84
+ # `{ "nodes" => [...] }`. Calling `dig("labels", "nodes")` on the Array form
85
+ # raises `TypeError: no implicit conversion of String into Integer`
86
+ # (Array#dig with a String key), which crashed every inbound issue apply.
87
+ # Normalize both shapes (and a missing key) to a plain Array.
88
+ def normalize_labels(labels)
89
+ case labels
90
+ when Array then labels
91
+ when Hash then labels["nodes"] || []
92
+ else []
93
+ end
94
+ end
95
+ private_class_method :normalize_labels
96
+
97
+ # Map a creative's ZERO-BASED sequence integer to a Linear priority (0-4).
98
+ #
99
+ # sequence 0-3 → 1-4 (0→Urgent, 3→Low)
100
+ # sequence 4 → 0 (the None sentinel, sorts after all ranked buckets)
101
+ # sequence nil or >4 → 0 (unranked / out-of-range → No priority)
102
+ def sequence_to_priority(sequence)
103
+ return 0 if sequence.nil?
104
+ return 0 if sequence < 0 || sequence > 3
105
+
106
+ sequence + 1
107
+ end
108
+ private_class_method :sequence_to_priority
109
+
110
+ # Map a Linear priority integer to a Collavre ZERO-BASED sequence integer.
111
+ #
112
+ # priority 1-4 → 0-3 (Urgent→0, Low→3)
113
+ # priority 0 → 4 (None sentinel, sorts last among siblings)
114
+ def priority_to_sequence(priority)
115
+ priority == 0 ? 4 : priority - 1
116
+ end
117
+ private_class_method :priority_to_sequence
118
+ end
119
+ end