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,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
|