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,199 @@
1
+ // Linear integration modal opener.
2
+ //
3
+ // The core integrations menu renders a hidden `#linear-integration-btn` and a
4
+ // menu item whose click-target controller clicks that button. This wires the
5
+ // button click to show `#linear-integration-modal`, mirroring the GitHub engine
6
+ // (collavre_github.js) which does the same for its `github-integration-*` ids.
7
+ //
8
+ // The modal is server-rendered (connect / link / linked states via ERB), but
9
+ // its forms POST to JSON endpoints — Turbo would treat the JSON reply as a
10
+ // failed navigation and leave the modal stuck in its old state. So we submit
11
+ // them via fetch() and reload on success, transitioning to the next state.
12
+
13
+ // Native window.confirm/alert don't render in the packaged Tauri desktop
14
+ // (WKWebView) — they resolve silently, so a confirm-gated action would fire
15
+ // unconfirmed and errors would vanish. Route through the shared in-app modal
16
+ // like the GitHub/Slack/Notion engines do.
17
+ import { alertDialog, confirmDialog } from 'collavre/lib/utils/dialog';
18
+
19
+ let linearIntegrationInitialized = false;
20
+
21
+ if (!linearIntegrationInitialized) {
22
+ linearIntegrationInitialized = true;
23
+
24
+ // The OAuth popup posts `linearConnected` to the opener when it closes.
25
+ // Reload so the modal re-renders in the connected (link-a-project) state.
26
+ // Bound to `window` once — it survives Turbo navigations, unlike the
27
+ // per-navigation element listeners set up in turbo:load below.
28
+ window.addEventListener('message', function (event) {
29
+ if (event.origin !== window.location.origin) return;
30
+ if (event.data && event.data.type === 'linearConnected') {
31
+ window.location.reload();
32
+ }
33
+ });
34
+
35
+ // Submit a modal form (link / resync / unlink / paste-secret) to its JSON
36
+ // endpoint and reload on success. Honors data-confirm and surfaces errors.
37
+ async function submitLinearModalForm(form, fallbackError) {
38
+ const confirmMsg = form.dataset.confirm;
39
+ // Only the destructive unlink form carries data-confirm.
40
+ if (confirmMsg && !(await confirmDialog(confirmMsg, { danger: true }))) return;
41
+
42
+ const genericError = fallbackError || 'Linear request failed.';
43
+ const token = document.querySelector('meta[name="csrf-token"]')?.content;
44
+ fetch(form.action, {
45
+ method: 'POST', // Rails method-override (_method field) handles DELETE
46
+ headers: {
47
+ Accept: 'application/json',
48
+ 'X-CSRF-Token': token || '',
49
+ 'X-Requested-With': 'XMLHttpRequest'
50
+ },
51
+ body: new FormData(form)
52
+ })
53
+ .then(async function (res) {
54
+ let data = {};
55
+ try { data = await res.json(); } catch (e) { /* non-JSON body */ }
56
+ if (res.ok && data.success) {
57
+ window.location.reload();
58
+ } else {
59
+ alertDialog(data.error || data.warning || genericError);
60
+ }
61
+ })
62
+ .catch(function () { alertDialog(genericError); });
63
+ }
64
+
65
+ // Populate the project/team dropdowns from the account's Linear workspace so
66
+ // users pick by name instead of typing raw IDs. Selecting a project scopes
67
+ // the team dropdown to that project's owning team(s) and auto-selects when a
68
+ // project has exactly one team. Fetched lazily (once) when the modal opens.
69
+ function loadLinkOptions(form) {
70
+ if (form.dataset.optionsLoaded === '1' || form.dataset.optionsLoading === '1') return;
71
+ form.dataset.optionsLoading = '1';
72
+
73
+ const status = form.querySelector('.linear-options-status');
74
+ const projectSelect = form.querySelector('select[name="linear_project_id"]');
75
+ const teamSelect = form.querySelector('select[name="team_id"]');
76
+ const submitBtn = form.querySelector('input[type="submit"],button[type="submit"]');
77
+ if (!projectSelect || !teamSelect) return;
78
+
79
+ const token = document.querySelector('meta[name="csrf-token"]')?.content;
80
+ fetch(form.dataset.optionsUrl, {
81
+ headers: { Accept: 'application/json', 'X-CSRF-Token': token || '', 'X-Requested-With': 'XMLHttpRequest' }
82
+ })
83
+ .then(async function (res) {
84
+ const data = await res.json().catch(function () { return {}; });
85
+ if (!res.ok) throw new Error(data.error || 'options failed');
86
+ return data;
87
+ })
88
+ .then(function (data) {
89
+ const teams = data.teams || [];
90
+ const projects = data.projects || [];
91
+ const teamName = {};
92
+ teams.forEach(function (t) { teamName[t.id] = t.name; });
93
+
94
+ if (projects.length === 0) {
95
+ if (status) status.textContent = form.dataset.noProjects;
96
+ return;
97
+ }
98
+
99
+ projects.forEach(function (p) {
100
+ const opt = document.createElement('option');
101
+ opt.value = p.id;
102
+ opt.textContent = p.name;
103
+ opt.dataset.teamIds = (p.team_ids || []).join(',');
104
+ projectSelect.appendChild(opt);
105
+ });
106
+
107
+ // Rebuild the team dropdown for the selected project's team(s).
108
+ function syncTeams() {
109
+ const chosen = projectSelect.selectedOptions[0];
110
+ const ids = (chosen && chosen.dataset.teamIds ? chosen.dataset.teamIds.split(',') : []).filter(Boolean);
111
+ // Keep the placeholder (first option), drop the rest.
112
+ while (teamSelect.options.length > 1) teamSelect.remove(1);
113
+ ids.forEach(function (id) {
114
+ const opt = document.createElement('option');
115
+ opt.value = id;
116
+ opt.textContent = teamName[id] || id;
117
+ teamSelect.appendChild(opt);
118
+ });
119
+ // Auto-select when the project has exactly one team.
120
+ teamSelect.value = ids.length === 1 ? ids[0] : '';
121
+ teamSelect.disabled = !projectSelect.value;
122
+ refreshSubmit();
123
+ }
124
+
125
+ function refreshSubmit() {
126
+ if (submitBtn) submitBtn.disabled = !(projectSelect.value && teamSelect.value);
127
+ }
128
+
129
+ projectSelect.addEventListener('change', syncTeams);
130
+ teamSelect.addEventListener('change', refreshSubmit);
131
+
132
+ projectSelect.disabled = false;
133
+ if (status) status.style.display = 'none';
134
+ form.dataset.optionsLoaded = '1';
135
+ syncTeams();
136
+ })
137
+ .catch(function () {
138
+ if (status) status.textContent = form.dataset.optionsFailed;
139
+ form.dataset.optionsLoading = '0'; // allow a retry on reopen
140
+ });
141
+ }
142
+
143
+ document.addEventListener('turbo:load', function () {
144
+ const openBtn = document.getElementById('linear-integration-btn');
145
+ const modal = document.getElementById('linear-integration-modal');
146
+ if (!openBtn || !modal) return;
147
+
148
+ const closeBtn = document.getElementById('close-linear-modal');
149
+
150
+ function showModal() {
151
+ modal.style.display = 'flex';
152
+ document.body.classList.add('no-scroll');
153
+ const linkForm = document.getElementById('linear-link-form');
154
+ if (linkForm && linkForm.dataset.optionsUrl) loadLinkOptions(linkForm);
155
+ }
156
+
157
+ function closeModal() {
158
+ modal.style.display = 'none';
159
+ document.body.classList.remove('no-scroll');
160
+ }
161
+
162
+ openBtn.addEventListener('click', function () {
163
+ showModal();
164
+ });
165
+
166
+ closeBtn?.addEventListener('click', closeModal);
167
+
168
+ modal.addEventListener('click', function (event) {
169
+ if (event.target === modal) closeModal();
170
+ });
171
+
172
+ // OAuth connect: open the popup first, then submit the hidden form with the
173
+ // native form.submit(). Turbo intercepts submit-button clicks (and would
174
+ // fetch() the store_creative endpoint, then auto-follow its 302 to Linear's
175
+ // cross-origin authorize URL → blocked by CORS). Native submit() is not
176
+ // intercepted, so the popup navigates for real and the redirect follows.
177
+ const connectBtn = document.getElementById('linear-connect-btn');
178
+ const connectForm = document.getElementById('linear-connect-form');
179
+ connectBtn?.addEventListener('click', function () {
180
+ const w = parseInt(connectBtn.dataset.windowWidth, 10) || 620;
181
+ const h = parseInt(connectBtn.dataset.windowHeight, 10) || 720;
182
+ const left = window.screenX + (window.outerWidth - w) / 2;
183
+ const top = window.screenY + (window.outerHeight - h) / 2;
184
+ window.open('', 'linear-auth-window', `width=${w},height=${h},left=${left},top=${top}`);
185
+ connectForm?.submit();
186
+ });
187
+
188
+ // Link / resync / unlink forms POST to JSON endpoints. Intercept their
189
+ // submit (the connect form is excluded — it's submitted natively above and
190
+ // form.submit() never fires this event) and drive them via fetch().
191
+ modal.querySelectorAll('form').forEach(function (form) {
192
+ if (form.id === 'linear-connect-form') return;
193
+ form.addEventListener('submit', function (event) {
194
+ event.preventDefault();
195
+ submitLinearModalForm(form, modal.dataset.errorGeneric);
196
+ });
197
+ });
198
+ });
199
+ }
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Applies a verified inbound Linear webhook payload to local state.
5
+ #
6
+ # Usage:
7
+ # CollavreLinear::InboundApplyJob.perform_later(payload)
8
+ #
9
+ # Signature verification, timestamp/replay checks, and echo suppression all
10
+ # happen in WebhooksController BEFORE this job is enqueued. By the time we run,
11
+ # the payload is trusted.
12
+ #
13
+ # The heavy lifting lives in CollavreLinear::InboundApplier (Task 10). Until
14
+ # that constant exists, this job no-ops gracefully so the webhook pipeline can
15
+ # ship and be validated independently.
16
+ class InboundApplyJob < ApplicationJob
17
+ # Inbound webhooks MUST apply in receipt order: a Comment can arrive before
18
+ # its issue's Create, and an Update before its Create. Running these on a
19
+ # multi-threaded queue lets workers apply them out of order — the comment
20
+ # then finds no IssueLink and is dropped. `linear_inbound` is served by a
21
+ # single-thread, single-process worker (config/queue.yml), making it a
22
+ # sequential FIFO queue that preserves enqueue (receipt) order.
23
+ queue_as :linear_inbound
24
+
25
+ def perform(payload)
26
+ unless CollavreLinear.const_defined?(:InboundApplier)
27
+ Rails.logger.info(
28
+ "[CollavreLinear::InboundApplyJob] InboundApplier not defined yet; skipping"
29
+ )
30
+ return
31
+ end
32
+
33
+ CollavreLinear::InboundApplier.new(payload).apply!
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Archives (soft-deletes) a Linear issue when the corresponding Collavre
5
+ # Creative is destroyed.
6
+ #
7
+ # Usage:
8
+ # CollavreLinear::OutboundArchiveJob.perform_later(linear_issue_id, account_id)
9
+ #
10
+ # The linear_issue_id and account_id are captured BEFORE the creative row is
11
+ # deleted (in CreativeSyncObserver#before_destroy) because after_commit on
12
+ # destroy runs after the row — and its dependent IssueLink — are gone.
13
+ class OutboundArchiveJob < ApplicationJob
14
+ queue_as :default
15
+
16
+ # Retries on transient Linear API errors.
17
+ retry_on CollavreLinear::Client::Error, wait: :polynomially_longer, attempts: 5
18
+
19
+ def perform(linear_issue_id, account_id)
20
+ account = CollavreLinear::Account.find(account_id)
21
+ client = CollavreLinear::Client.new(account)
22
+ client.archive_issue(linear_issue_id)
23
+ rescue ActiveRecord::RecordNotFound
24
+ Rails.logger.info(
25
+ "[CollavreLinear::OutboundArchiveJob] Account #{account_id} not found; skipping archive for issue #{linear_issue_id}"
26
+ )
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Deletes the mirrored Linear comment when a Collavre::Comment is destroyed.
5
+ #
6
+ # CollavreLinear::OutboundCommentDeleteJob.perform_later(comment_link.id)
7
+ #
8
+ # Keyed on the CommentLink id (not the comment id): the local comment is already
9
+ # hard-deleted by the time this runs, so the link is the only surviving handle
10
+ # on the Linear comment id. Idempotent: a re-run after the link is torn down
11
+ # finds nothing and does nothing.
12
+ class OutboundCommentDeleteJob < ApplicationJob
13
+ queue_as :default
14
+
15
+ retry_on CollavreLinear::Client::Error, wait: :polynomially_longer, attempts: 5
16
+
17
+ def perform(comment_link_id)
18
+ comment_link = CollavreLinear::CommentLink.find_by(id: comment_link_id)
19
+ return unless comment_link
20
+
21
+ account = comment_link.issue_link.project_link.account
22
+ return unless account
23
+
24
+ comment_link.with_lock do
25
+ # This delete was enqueued because the comment went private / left Main.
26
+ # If it became visible again before the job ran, its CommentLink still
27
+ # exists and the observer's update path enqueues NO recreate (only
28
+ # private/topic_id changed, not content), so tearing the mirror down now
29
+ # would strand the re-visible comment off Linear until its next edit.
30
+ # Re-check under the lock (mirrors the update job): keep the mirror when
31
+ # the comment is syncable again. A hard-deleted comment is gone → not
32
+ # syncable → teardown proceeds as before.
33
+ comment = ::Collavre::Comment.find_by(id: comment_link.comment_id)
34
+ return if comment && CollavreLinear::CommentSyncability.syncable?(comment)
35
+
36
+ CollavreLinear::Client.new(account).delete_comment(comment_link.linear_comment_id)
37
+ comment_link.destroy
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Pushes a single Collavre::Comment to its linked Linear issue as a comment.
5
+ #
6
+ # CollavreLinear::OutboundCommentSyncJob.perform_later(comment.id)
7
+ #
8
+ # Idempotent: the IssueLink row is locked and the CommentLink existence is
9
+ # re-checked inside the lock, so concurrent performs (or a duplicate enqueue)
10
+ # never post the same chat message to Linear twice.
11
+ class OutboundCommentSyncJob < ApplicationJob
12
+ queue_as :default
13
+
14
+ retry_on CollavreLinear::Client::Error, wait: :polynomially_longer, attempts: 5
15
+
16
+ def perform(comment_id)
17
+ comment = ::Collavre::Comment.find(comment_id)
18
+
19
+ issue_link = comment.creative&.linear_issue_links&.first
20
+ return if issue_link&.linear_issue_id.blank?
21
+
22
+ account = issue_link.project_link.account
23
+ return unless account
24
+
25
+ # Captured inside the lock so the self-echo rescue can reconcile AFTER the
26
+ # lock's transaction rolls back — rescuing the unique violation inside the
27
+ # `with_lock` transaction would poison it (Postgres aborts the whole tx).
28
+ posted = nil
29
+
30
+ begin
31
+ issue_link.with_lock do
32
+ # Re-check under the lock: an inbound mirror or a prior run may have
33
+ # linked this comment already. One Collavre comment -> one Linear comment.
34
+ return if CollavreLinear::CommentLink.exists?(comment_id: comment.id)
35
+
36
+ # Re-check visibility at run time: the comment was public/Main when the
37
+ # observer enqueued this create, but may have been made private or moved
38
+ # out of Main since. No CommentLink exists yet, so the observer cannot
39
+ # enqueue a delete for that change — posting the now-hidden body would
40
+ # leak it. Same predicate the update job re-runs; here it must no-op.
41
+ return unless CollavreLinear::CommentSyncability.syncable?(comment)
42
+
43
+ posted = CollavreLinear::Client.new(account).create_comment(
44
+ issue_id: issue_link.linear_issue_id,
45
+ body: CollavreLinear::CommentFormatter.outbound_body(comment)
46
+ )
47
+ return if posted[:id].blank?
48
+
49
+ # Record Linear's updatedAt for this version so the inbound applier can
50
+ # recognise the create webhook (and any stale echo of it) as our own by
51
+ # timestamp rather than by comparing against the mutable local body.
52
+ CollavreLinear::CommentLink.create!(
53
+ comment_id: comment.id,
54
+ linear_comment_id: posted[:id],
55
+ issue_link: issue_link,
56
+ remote_updated_at: posted[:updatedAt]
57
+ )
58
+ end
59
+ rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
60
+ # Self-echo race: the inbound create webhook for the comment we just posted
61
+ # landed first (app_actor_id is nil, so EchoGuard can't drop it), mirrored
62
+ # it locally, and grabbed the unique linear_comment_id. Adopt that link onto
63
+ # our original comment and drop the duplicate mirror — otherwise the comment
64
+ # is double-represented and our original stays unlinked, so a later edit
65
+ # re-posts it. Mirrors CreativeExporter#reconcile_self_echo!. Any other
66
+ # validation error must still surface.
67
+ raise if e.is_a?(ActiveRecord::RecordInvalid) && !duplicate_comment_link_error?(e)
68
+
69
+ reconcile_comment_self_echo!(comment, issue_link, posted)
70
+ end
71
+ rescue ActiveRecord::RecordNotFound
72
+ Rails.logger.info(
73
+ "[CollavreLinear::OutboundCommentSyncJob] Comment #{comment_id} not found; skipping"
74
+ )
75
+ end
76
+
77
+ private
78
+
79
+ # True when the failure is exactly the CommentLink linear_comment_id
80
+ # uniqueness collision (the self-echo race), not some other invalid record.
81
+ def duplicate_comment_link_error?(error)
82
+ record = error.record
83
+ record.is_a?(CollavreLinear::CommentLink) &&
84
+ record.errors.of_kind?(:linear_comment_id, :taken)
85
+ end
86
+
87
+ def reconcile_comment_self_echo!(comment, issue_link, posted)
88
+ linear_comment_id = posted && posted[:id]
89
+ return if linear_comment_id.blank?
90
+
91
+ existing = CollavreLinear::CommentLink.find_by(linear_comment_id: linear_comment_id)
92
+ return unless existing
93
+
94
+ mirror = ::Collavre::Comment.find_by(id: existing.comment_id)
95
+ CollavreLinear::CommentLink.transaction do
96
+ existing.update!(
97
+ comment_id: comment.id,
98
+ issue_link: issue_link,
99
+ remote_updated_at: posted[:updatedAt]
100
+ )
101
+
102
+ # Remove the echo duplicate. The link was reassigned above, so the mirror
103
+ # no longer owns it; skip_linear_sync stops the destroy observer from
104
+ # posting a Linear delete for the comment we just created.
105
+ if mirror && mirror.id != comment.id
106
+ mirror.skip_linear_sync = true
107
+ mirror.destroy!
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Pushes a local edit of a Collavre::Comment to its mirrored Linear comment.
5
+ #
6
+ # CollavreLinear::OutboundCommentUpdateJob.perform_later(comment.id)
7
+ #
8
+ # No-op when the comment (or its CommentLink) is gone: a delete may have raced
9
+ # ahead, and the delete job owns tearing down the Linear comment.
10
+ class OutboundCommentUpdateJob < ApplicationJob
11
+ queue_as :default
12
+
13
+ retry_on CollavreLinear::Client::Error, wait: :polynomially_longer, attempts: 5
14
+
15
+ def perform(comment_id)
16
+ comment = ::Collavre::Comment.find(comment_id)
17
+
18
+ comment_link = CollavreLinear::CommentLink.find_by(comment_id: comment.id)
19
+ return unless comment_link
20
+
21
+ # Serialize edits of the same mirrored comment. Two quick edits enqueue two
22
+ # jobs; without the lock one can read body A, the other body B, and if B's
23
+ # Linear call completes first the older job overwrites Linear back to A while
24
+ # advancing the baseline — leaving Linear stale until the next edit. Locking
25
+ # the CommentLink and reloading the comment inside the lock means every run
26
+ # pushes the current committed body in enqueue order, so the last write wins.
27
+ comment_link.with_lock do
28
+ comment.reload
29
+
30
+ # Re-check visibility under the lock: the comment may have been made
31
+ # private or moved out of Main between enqueue and now. The observer
32
+ # enqueues a delete for that change, but with multiple workers this update
33
+ # could run first and leak the now-hidden body. The delete job owns
34
+ # teardown; no-op.
35
+ return unless CollavreLinear::CommentSyncability.syncable?(comment)
36
+
37
+ account = comment_link.issue_link.project_link.account
38
+ return unless account
39
+
40
+ result = CollavreLinear::Client.new(account).update_comment(
41
+ id: comment_link.linear_comment_id,
42
+ body: CollavreLinear::CommentFormatter.outbound_body(comment)
43
+ )
44
+
45
+ # Advance the synced baseline so the echo of this edit (and any stale echo
46
+ # of an earlier body) is recognised as ours by the inbound applier.
47
+ comment_link.update!(remote_updated_at: result[:updatedAt]) if result[:updatedAt].present?
48
+ end
49
+ rescue ActiveRecord::RecordNotFound
50
+ Rails.logger.info(
51
+ "[CollavreLinear::OutboundCommentUpdateJob] Comment #{comment_id} not found; skipping"
52
+ )
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ # Enqueues an outbound sync for a single Creative to Linear.
5
+ #
6
+ # Usage:
7
+ # CollavreLinear::OutboundSyncJob.perform_later(creative.id)
8
+ #
9
+ # The critical section is guarded with a row-level lock on the IssueLink (or
10
+ # the ProjectLink's creative row when no IssueLink exists yet) so concurrent
11
+ # performs cannot double-create issues.
12
+ class OutboundSyncJob < ApplicationJob
13
+ queue_as :default
14
+
15
+ # Retries on transient Linear API errors.
16
+ retry_on CollavreLinear::Client::Error, wait: :polynomially_longer, attempts: 5
17
+
18
+ # Re-enqueue a child whose parent's Linear issue does not exist yet, so the
19
+ # child lands AFTER its parent and nests correctly. Independent per-creative
20
+ # jobs can otherwise run out of order under multiple workers and flatten the
21
+ # tree. Generous attempts so a slow/retrying parent export still resolves.
22
+ retry_on CollavreLinear::CreativeExporter::ParentNotExportedError,
23
+ wait: :polynomially_longer, attempts: 25
24
+
25
+ def perform(creative_id)
26
+ creative = ::Collavre::Creative.find(creative_id)
27
+
28
+ # Resolve an existing IssueLink (or the creative itself) to lock on.
29
+ # We lock the creative row so concurrent jobs serialize without racing to
30
+ # create duplicate IssueLinks.
31
+ creative.with_lock do
32
+ CollavreLinear::CreativeExporter.new(creative).sync!
33
+ end
34
+ rescue ActiveRecord::RecordNotFound
35
+ # Creative was deleted — nothing to sync.
36
+ Rails.logger.info("[CollavreLinear::OutboundSyncJob] Creative #{creative_id} not found; skipping")
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ class Account < ApplicationRecord
5
+ self.table_name = "linear_accounts"
6
+
7
+ belongs_to :user, class_name: "::User"
8
+
9
+ # account_id carries a RESTRICT foreign key, so a user delete (which cascades
10
+ # here via has_one :linear_account, dependent: :destroy) would hit a FK
11
+ # violation unless we tear the links down first. Cascades on to issue/comment
12
+ # links via ProjectLink's own dependent chain.
13
+ has_many :project_links, class_name: "CollavreLinear::ProjectLink", dependent: :destroy
14
+
15
+ encrypts :access_token, deterministic: false
16
+ encrypts :refresh_token, deterministic: false
17
+
18
+ validates :linear_uid, presence: true, uniqueness: true
19
+ validates :access_token, presence: true
20
+
21
+ def token_expired?
22
+ token_expires_at.present? && token_expires_at < Time.current
23
+ end
24
+
25
+ def token_expiring_soon?(within: 5.minutes)
26
+ token_expires_at.present? && token_expires_at < Time.current + within
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ class ApplicationRecord < ActiveRecord::Base
5
+ self.abstract_class = true
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ class CommentLink < ApplicationRecord
5
+ self.table_name = "linear_comment_links"
6
+
7
+ belongs_to :issue_link, class_name: "CollavreLinear::IssueLink"
8
+
9
+ validates :comment_id, presence: true
10
+ validates :linear_comment_id, presence: true, uniqueness: true
11
+ end
12
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ class IssueLink < ApplicationRecord
5
+ self.table_name = "linear_issue_links"
6
+
7
+ belongs_to :creative, class_name: "::Collavre::Creative"
8
+ belongs_to :project_link, class_name: "CollavreLinear::ProjectLink"
9
+
10
+ # linear_comment_links.issue_link_id has an FK to this row; cascade so the
11
+ # ProjectLink -> IssueLink -> CommentLink chain cleans up fully on unlink.
12
+ has_many :comment_links, class_name: "CollavreLinear::CommentLink", dependent: :destroy
13
+
14
+ enum :sync_state, { synced: 0, dirty: 1, syncing: 2, conflict: 3 }, prefix: false
15
+
16
+ validates :linear_issue_id, presence: true, uniqueness: true
17
+ validates :creative_id, uniqueness: true
18
+ validates :project_link, presence: true
19
+ end
20
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreLinear
4
+ class ProjectLink < ApplicationRecord
5
+ self.table_name = "linear_project_links"
6
+
7
+ belongs_to :creative, class_name: "::Collavre::Creative"
8
+ belongs_to :account, class_name: "CollavreLinear::Account"
9
+
10
+ # linear_issue_links.project_link_id has an FK to this row; unlinking must
11
+ # cascade so link.destroy! does not 500 once issues have synced.
12
+ has_many :issue_links, class_name: "CollavreLinear::IssueLink", dependent: :destroy
13
+
14
+ # The HMAC secret Linear signs webhook deliveries with. Linear GENERATES this
15
+ # when the webhook is created — it can't be forced to a value we pick — so the
16
+ # admin pastes Linear's secret in (we never mint our own; app actors also lack
17
+ # the admin scope to auto-provision). Blank until pasted, which is fine: inbound
18
+ # deliveries just 401 until then (WebhooksController). Encrypted at rest so a
19
+ # DB/backup leak can't forge webhooks (mirrors Account tokens).
20
+ encrypts :webhook_secret, deterministic: false
21
+
22
+ enum :sync_state, { synced: 0, dirty: 1, syncing: 2, conflict: 3 }, prefix: false
23
+
24
+ validates :linear_project_id, presence: true
25
+ validates :team_id, presence: true
26
+
27
+ # A team's single Linear webhook signs every delivery with one secret, so a
28
+ # second project of an already-configured team inherits that secret rather
29
+ # than asking the admin to paste it again (no-op for a brand-new team).
30
+ before_create :adopt_team_webhook_secret
31
+
32
+ scope :auto_syncable, -> { where(sync_state: %i[synced dirty]) }
33
+
34
+ # Store the signing secret the admin copied from Linear's webhook settings,
35
+ # propagating it to every sibling ProjectLink of the team. Linear signs all of
36
+ # a team's deliveries with the one secret its single webhook carries, and
37
+ # find_project_link resolves a delivery to an arbitrary team sibling, so all
38
+ # must hold the same value or a sibling 401s. update_column applies encryption
39
+ # (a raw write would leak plaintext); assign self last so its in-memory
40
+ # attribute reflects the pasted value.
41
+ def update_webhook_secret!(secret)
42
+ self.class.where(team_id: team_id).where.not(id: id).find_each do |sibling|
43
+ sibling.update_column(:webhook_secret, secret)
44
+ end
45
+ update_column(:webhook_secret, secret)
46
+ secret
47
+ end
48
+
49
+ # The one signing secret a team's single Linear webhook uses, denormalized
50
+ # across the team's sibling ProjectLinks. Returns any non-blank sibling's
51
+ # value: a row created concurrently with the admin's paste can commit blank
52
+ # (adopt ran before the paste was visible), and webhook verification may
53
+ # resolve a delivery to that blank row — so the TEAM's secret, not one
54
+ # arbitrary row's column, is the authority. Reads the decrypted attribute
55
+ # (a raw pick would be ciphertext). Nil when the team has no secret yet.
56
+ def self.team_webhook_secret(team_id)
57
+ return if team_id.blank?
58
+
59
+ where(team_id: team_id).filter_map { |link| link.webhook_secret.presence }.first
60
+ end
61
+
62
+ private
63
+
64
+ # Inherit an existing team sibling's pasted secret so the admin configures one
65
+ # webhook per team, not per project. webhook_secret is encrypted
66
+ # (non-deterministic), so a raw column pick would return ciphertext that
67
+ # re-encrypts into a double-wrapped value → HMAC mismatch; read the DECRYPTED
68
+ # attribute off loaded siblings instead. Any sibling secret is valid since a
69
+ # team shares one. Skips when already set or the team has no secret yet.
70
+ def adopt_team_webhook_secret
71
+ return if webhook_secret.present? || team_id.blank?
72
+
73
+ self.webhook_secret = self.class.team_webhook_secret(team_id)
74
+ end
75
+ end
76
+ end