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