collavre_linear 0.1.0 → 0.2.1
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 +4 -4
- data/app/controllers/collavre_linear/creatives/integrations_controller.rb +10 -1
- data/app/controllers/collavre_linear/webhooks_controller.rb +1 -1
- data/app/javascript/__tests__/linear_modal_reopen.test.js +178 -0
- data/app/javascript/collavre_linear.js +59 -3
- data/app/javascript/linear_modal_reopen.js +74 -0
- data/app/jobs/collavre_linear/inbound_apply_job.rb +20 -0
- data/app/observers/collavre_linear/creative_sync_observer.rb +6 -2
- data/app/services/collavre_linear/client.rb +110 -18
- data/app/services/collavre_linear/creative_exporter.rb +125 -4
- data/app/services/collavre_linear/inbound_applier.rb +46 -0
- data/app/views/collavre_linear/auth/setup.html.erb +24 -4
- data/app/views/collavre_linear/integrations/_modal.html.erb +17 -0
- data/config/locales/en.yml +3 -0
- data/config/locales/ko.yml +3 -0
- data/db/migrate/20260704000000_add_done_state_id_to_linear_project_links.rb +9 -0
- data/lib/collavre_linear/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d8004796cae3abe1389345fbd85959f2390e8d8930768c86045ba202ca94c5e1
|
|
4
|
+
data.tar.gz: 0e6f414178db0ee5513f238cac0fabf75a44b4eea80a1f7142ce94487bbff798
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 258031f64f076de07891357445af346b6bc10c75d43fb802d2a749eb1c7069bc4d81ca64a66366b0875b2f971c8bd986c223fb133ca47dcbc0ed9c86d936cf37
|
|
7
|
+
data.tar.gz: 07b41024a06d0d5074a43685a2dfbab697f92990fb6100849fcf893c38159665c553254bdaca8f40f5ea209b632dc6b880d583a133047a80a47cb0e89d2c25c0
|
|
@@ -88,6 +88,10 @@ module CollavreLinear
|
|
|
88
88
|
linear_project_id: linear_project_id
|
|
89
89
|
)
|
|
90
90
|
link.team_id = team_id
|
|
91
|
+
# The Linear workflow-state UUID the admin picked as "done" (combobox,
|
|
92
|
+
# default Completed). Blank when the team has no completed state or the
|
|
93
|
+
# picker was cleared — completion mapping then no-ops both directions.
|
|
94
|
+
link.done_state_id = params[:done_state_id].to_s.presence
|
|
91
95
|
link.save!
|
|
92
96
|
end
|
|
93
97
|
end
|
|
@@ -224,7 +228,11 @@ module CollavreLinear
|
|
|
224
228
|
end
|
|
225
229
|
|
|
226
230
|
client = CollavreLinear::Client.new(account)
|
|
227
|
-
render json: {
|
|
231
|
+
render json: {
|
|
232
|
+
teams: client.list_teams,
|
|
233
|
+
projects: client.list_projects,
|
|
234
|
+
states: client.list_workflow_states
|
|
235
|
+
}
|
|
228
236
|
rescue CollavreLinear::Client::Error => e
|
|
229
237
|
# Surface the Linear-side failure (expired token, revoked scope) instead
|
|
230
238
|
# of leaving the dropdowns silently empty.
|
|
@@ -262,6 +270,7 @@ module CollavreLinear
|
|
|
262
270
|
id: link.id,
|
|
263
271
|
team_id: link.team_id,
|
|
264
272
|
linear_project_id: link.linear_project_id,
|
|
273
|
+
done_state_id: link.done_state_id,
|
|
265
274
|
sync_state: link.sync_state,
|
|
266
275
|
webhook_id: link.webhook_id
|
|
267
276
|
}
|
|
@@ -178,7 +178,7 @@ module CollavreLinear
|
|
|
178
178
|
return false if ts.blank?
|
|
179
179
|
|
|
180
180
|
ts_ms = Integer(ts)
|
|
181
|
-
now_ms = (Time.
|
|
181
|
+
now_ms = (Time.current.to_f * 1000).to_i
|
|
182
182
|
(now_ms - ts_ms).abs <= TIMESTAMP_WINDOW_MS
|
|
183
183
|
rescue ArgumentError, TypeError
|
|
184
184
|
false
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import {
|
|
2
|
+
LINEAR_REOPEN_KEY,
|
|
3
|
+
markReopenAfterConnect,
|
|
4
|
+
consumeReopenAfterConnect,
|
|
5
|
+
safeSessionStorage,
|
|
6
|
+
} from '../linear_modal_reopen.js';
|
|
7
|
+
|
|
8
|
+
// Minimal in-memory Storage stand-in (jsdom's is fine too, but this keeps the
|
|
9
|
+
// unit test free of environment setup and lets us model a throwing storage).
|
|
10
|
+
function fakeStorage() {
|
|
11
|
+
const map = new Map();
|
|
12
|
+
return {
|
|
13
|
+
setItem: (k, v) => map.set(k, String(v)),
|
|
14
|
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
|
15
|
+
removeItem: (k) => map.delete(k),
|
|
16
|
+
_size: () => map.size,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('safeSessionStorage', () => {
|
|
21
|
+
test('returns the storage object when Web Storage is available', () => {
|
|
22
|
+
// jsdom provides a working window.sessionStorage.
|
|
23
|
+
expect(safeSessionStorage()).toBe(window.sessionStorage);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('returns null when the window.sessionStorage getter itself throws', () => {
|
|
27
|
+
// Web Storage disabled by policy (packaged WKWebView, storage-blocked
|
|
28
|
+
// browser) throws on the property *access*, before any value is passed to
|
|
29
|
+
// mark/consume. safeSessionStorage must absorb it so callers never throw.
|
|
30
|
+
const original = Object.getOwnPropertyDescriptor(window, 'sessionStorage');
|
|
31
|
+
Object.defineProperty(window, 'sessionStorage', {
|
|
32
|
+
configurable: true,
|
|
33
|
+
get() { throw new Error('SecurityError: storage disabled'); },
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
expect(() => safeSessionStorage()).not.toThrow();
|
|
37
|
+
expect(safeSessionStorage()).toBeNull();
|
|
38
|
+
} finally {
|
|
39
|
+
if (original) Object.defineProperty(window, 'sessionStorage', original);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('markReopenAfterConnect', () => {
|
|
45
|
+
test('persists the unscoped sentinel when no creative id is given', () => {
|
|
46
|
+
const storage = fakeStorage();
|
|
47
|
+
markReopenAfterConnect(storage);
|
|
48
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBe('*');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('the unscoped sentinel cannot equal any serialized creative id', () => {
|
|
52
|
+
// Creative ids are positive integers → digit strings. The sentinel must be a
|
|
53
|
+
// non-digit so a real id never gets misread as "unscoped".
|
|
54
|
+
const storage = fakeStorage();
|
|
55
|
+
markReopenAfterConnect(storage);
|
|
56
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).not.toMatch(/^\d+$/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('persists the creative id when given (scoped reopen)', () => {
|
|
60
|
+
const storage = fakeStorage();
|
|
61
|
+
markReopenAfterConnect(storage, 42);
|
|
62
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBe('42');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('falls back to the unscoped sentinel for an empty creative id', () => {
|
|
66
|
+
const storage = fakeStorage();
|
|
67
|
+
markReopenAfterConnect(storage, '');
|
|
68
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBe('*');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('tolerates a null storage without throwing', () => {
|
|
72
|
+
expect(() => markReopenAfterConnect(null)).not.toThrow();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('swallows storage errors (private mode / WKWebView)', () => {
|
|
76
|
+
const throwing = { setItem: () => { throw new Error('QuotaExceeded'); } };
|
|
77
|
+
expect(() => markReopenAfterConnect(throwing)).not.toThrow();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('consumeReopenAfterConnect', () => {
|
|
82
|
+
test('returns true exactly once, then clears the flag (regression)', () => {
|
|
83
|
+
// Without the one-shot clear, every subsequent reload — including the one
|
|
84
|
+
// after a successful link — would reopen the modal. It must fire once.
|
|
85
|
+
const storage = fakeStorage();
|
|
86
|
+
markReopenAfterConnect(storage);
|
|
87
|
+
|
|
88
|
+
expect(consumeReopenAfterConnect(storage)).toBe(true);
|
|
89
|
+
expect(consumeReopenAfterConnect(storage)).toBe(false);
|
|
90
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('reopens only when the current creative matches the stored one', () => {
|
|
94
|
+
const storage = fakeStorage();
|
|
95
|
+
markReopenAfterConnect(storage, 42); // popup connected creative 42
|
|
96
|
+
// Opener is on the same creative → reopen.
|
|
97
|
+
expect(consumeReopenAfterConnect(storage, 42)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('does NOT reopen when the opener navigated to a different creative', () => {
|
|
101
|
+
// Codex P2: mid-flow navigation must not surface the wrong creative's modal.
|
|
102
|
+
const storage = fakeStorage();
|
|
103
|
+
markReopenAfterConnect(storage, 42); // popup connected creative 42
|
|
104
|
+
// Opener moved to creative 99 before the reload → no reopen, flag cleared.
|
|
105
|
+
expect(consumeReopenAfterConnect(storage, 99)).toBe(false);
|
|
106
|
+
expect(storage.getItem(LINEAR_REOPEN_KEY)).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('creative id 1 stays scoped and does NOT reopen a different creative', () => {
|
|
110
|
+
// Regression: the sentinel used to be '1', colliding with creative id 1 — so
|
|
111
|
+
// OAuth from creative 1 was misread as unscoped and reopened whatever modal
|
|
112
|
+
// the opener had navigated to. It must behave like any other scoped id.
|
|
113
|
+
const storage = fakeStorage();
|
|
114
|
+
markReopenAfterConnect(storage, 1); // popup connected creative 1
|
|
115
|
+
expect(consumeReopenAfterConnect(storage, 99)).toBe(false); // opener moved away
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('creative id 1 reopens when the opener is still on creative 1', () => {
|
|
119
|
+
const storage = fakeStorage();
|
|
120
|
+
markReopenAfterConnect(storage, 1);
|
|
121
|
+
expect(consumeReopenAfterConnect(storage, 1)).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('clears a mismatched intent so it never fires on a later matching visit', () => {
|
|
125
|
+
const storage = fakeStorage();
|
|
126
|
+
markReopenAfterConnect(storage, 42);
|
|
127
|
+
consumeReopenAfterConnect(storage, 99); // mismatch on the reload
|
|
128
|
+
// Later navigation back to creative 42 must NOT auto-open — intent was spent.
|
|
129
|
+
expect(consumeReopenAfterConnect(storage, 42)).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('matches across id string/number coercion', () => {
|
|
133
|
+
const storage = fakeStorage();
|
|
134
|
+
markReopenAfterConnect(storage, 42); // stored as '42'
|
|
135
|
+
expect(consumeReopenAfterConnect(storage, '42')).toBe(true); // dataset is a string
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('unscoped sentinel reopens regardless of current creative (legacy)', () => {
|
|
139
|
+
const storage = fakeStorage();
|
|
140
|
+
markReopenAfterConnect(storage); // no id → '1'
|
|
141
|
+
expect(consumeReopenAfterConnect(storage, 7)).toBe(true);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('returns false when no reopen was pending', () => {
|
|
145
|
+
expect(consumeReopenAfterConnect(fakeStorage())).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('tolerates a null storage', () => {
|
|
149
|
+
expect(consumeReopenAfterConnect(null)).toBe(false);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('swallows storage errors and reports no pending reopen', () => {
|
|
153
|
+
const throwing = { getItem: () => { throw new Error('SecurityError'); } };
|
|
154
|
+
expect(consumeReopenAfterConnect(throwing)).toBe(false);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('end-to-end via safeSessionStorage: mark on connect, consume once', () => {
|
|
158
|
+
// The call sites pass safeSessionStorage() rather than window.sessionStorage
|
|
159
|
+
// directly, so the whole flow must survive a storage-backed session.
|
|
160
|
+
const s = safeSessionStorage();
|
|
161
|
+
if (!s) return; // jsdom always provides it; guard just in case
|
|
162
|
+
s.removeItem(LINEAR_REOPEN_KEY);
|
|
163
|
+
markReopenAfterConnect(s);
|
|
164
|
+
expect(consumeReopenAfterConnect(s)).toBe(true);
|
|
165
|
+
expect(consumeReopenAfterConnect(s)).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('end-to-end: mark on connect, consume on the next load', () => {
|
|
169
|
+
// Mirrors the real flow: the postMessage handler marks, the reload happens,
|
|
170
|
+
// then turbo:load consumes the flag and opens the modal exactly once.
|
|
171
|
+
const storage = fakeStorage();
|
|
172
|
+
markReopenAfterConnect(storage); // linearConnected received
|
|
173
|
+
const reopenedFirstLoad = consumeReopenAfterConnect(storage); // turbo:load
|
|
174
|
+
const reopenedSecondLoad = consumeReopenAfterConnect(storage); // later nav
|
|
175
|
+
expect(reopenedFirstLoad).toBe(true);
|
|
176
|
+
expect(reopenedSecondLoad).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -15,19 +15,38 @@
|
|
|
15
15
|
// unconfirmed and errors would vanish. Route through the shared in-app modal
|
|
16
16
|
// like the GitHub/Slack/Notion engines do.
|
|
17
17
|
import { alertDialog, confirmDialog } from 'collavre/lib/utils/dialog';
|
|
18
|
+
import {
|
|
19
|
+
markReopenAfterConnect,
|
|
20
|
+
consumeReopenAfterConnect,
|
|
21
|
+
safeSessionStorage,
|
|
22
|
+
} from './linear_modal_reopen.js';
|
|
18
23
|
|
|
19
24
|
let linearIntegrationInitialized = false;
|
|
20
25
|
|
|
21
26
|
if (!linearIntegrationInitialized) {
|
|
22
27
|
linearIntegrationInitialized = true;
|
|
23
28
|
|
|
24
|
-
// The OAuth popup posts `linearConnected` to the opener
|
|
25
|
-
// Reload so the modal re-renders in the connected (link-a-project)
|
|
29
|
+
// The OAuth popup posts `linearConnected` to the opener once the account is
|
|
30
|
+
// connected. Reload so the modal re-renders in the connected (link-a-project)
|
|
31
|
+
// state, then reopen it (see turbo:load) so the user lands directly on the
|
|
32
|
+
// project settings step instead of a closed modal.
|
|
26
33
|
// Bound to `window` once — it survives Turbo navigations, unlike the
|
|
27
34
|
// per-navigation element listeners set up in turbo:load below.
|
|
28
35
|
window.addEventListener('message', function (event) {
|
|
29
36
|
if (event.origin !== window.location.origin) return;
|
|
30
37
|
if (event.data && event.data.type === 'linearConnected') {
|
|
38
|
+
// Close the popup from here. window.close() inside the popup is unreliable
|
|
39
|
+
// after the cross-origin OAuth round trip and in the desktop WebView, so
|
|
40
|
+
// the popup's own "Close" button looked dead — closing the window we
|
|
41
|
+
// opened is the reliable path.
|
|
42
|
+
try {
|
|
43
|
+
if (event.source && !event.source.closed) event.source.close();
|
|
44
|
+
} catch (e) { /* cross-origin or already closed */ }
|
|
45
|
+
// Survive the reload: reopen the modal on the next load so the flow lands
|
|
46
|
+
// on the project-link step automatically. Scope it to the creative the
|
|
47
|
+
// popup was opened for (event.data.creativeId) so a mid-flow navigation to
|
|
48
|
+
// a different creative doesn't auto-open the wrong creative's link modal.
|
|
49
|
+
markReopenAfterConnect(safeSessionStorage(), event.data.creativeId);
|
|
31
50
|
window.location.reload();
|
|
32
51
|
}
|
|
33
52
|
});
|
|
@@ -73,6 +92,7 @@ if (!linearIntegrationInitialized) {
|
|
|
73
92
|
const status = form.querySelector('.linear-options-status');
|
|
74
93
|
const projectSelect = form.querySelector('select[name="linear_project_id"]');
|
|
75
94
|
const teamSelect = form.querySelector('select[name="team_id"]');
|
|
95
|
+
const stateSelect = form.querySelector('select[name="done_state_id"]');
|
|
76
96
|
const submitBtn = form.querySelector('input[type="submit"],button[type="submit"]');
|
|
77
97
|
if (!projectSelect || !teamSelect) return;
|
|
78
98
|
|
|
@@ -88,6 +108,7 @@ if (!linearIntegrationInitialized) {
|
|
|
88
108
|
.then(function (data) {
|
|
89
109
|
const teams = data.teams || [];
|
|
90
110
|
const projects = data.projects || [];
|
|
111
|
+
const states = data.states || [];
|
|
91
112
|
const teamName = {};
|
|
92
113
|
teams.forEach(function (t) { teamName[t.id] = t.name; });
|
|
93
114
|
|
|
@@ -119,15 +140,41 @@ if (!linearIntegrationInitialized) {
|
|
|
119
140
|
// Auto-select when the project has exactly one team.
|
|
120
141
|
teamSelect.value = ids.length === 1 ? ids[0] : '';
|
|
121
142
|
teamSelect.disabled = !projectSelect.value;
|
|
143
|
+
syncStates();
|
|
122
144
|
refreshSubmit();
|
|
123
145
|
}
|
|
124
146
|
|
|
147
|
+
// Rebuild the done-state dropdown for the selected team and default to
|
|
148
|
+
// that team's Completed state. Optional field — no completed state (or no
|
|
149
|
+
// team chosen) leaves it blank and completion mapping stays off.
|
|
150
|
+
function syncStates() {
|
|
151
|
+
if (!stateSelect) return;
|
|
152
|
+
const teamId = teamSelect.value;
|
|
153
|
+
while (stateSelect.options.length > 1) stateSelect.remove(1);
|
|
154
|
+
const teamStates = states
|
|
155
|
+
.filter(function (s) { return s.team_id === teamId; })
|
|
156
|
+
.sort(function (a, b) { return (a.position || 0) - (b.position || 0); });
|
|
157
|
+
teamStates.forEach(function (s) {
|
|
158
|
+
const opt = document.createElement('option');
|
|
159
|
+
opt.value = s.id;
|
|
160
|
+
opt.textContent = s.name;
|
|
161
|
+
opt.dataset.type = s.type || '';
|
|
162
|
+
stateSelect.appendChild(opt);
|
|
163
|
+
});
|
|
164
|
+
const completed = teamStates.filter(function (s) { return s.type === 'completed'; });
|
|
165
|
+
const named = completed.find(function (s) {
|
|
166
|
+
return (s.name || '').toLowerCase() === 'completed';
|
|
167
|
+
});
|
|
168
|
+
stateSelect.value = named ? named.id : (completed[0] ? completed[0].id : '');
|
|
169
|
+
stateSelect.disabled = !teamId;
|
|
170
|
+
}
|
|
171
|
+
|
|
125
172
|
function refreshSubmit() {
|
|
126
173
|
if (submitBtn) submitBtn.disabled = !(projectSelect.value && teamSelect.value);
|
|
127
174
|
}
|
|
128
175
|
|
|
129
176
|
projectSelect.addEventListener('change', syncTeams);
|
|
130
|
-
teamSelect.addEventListener('change', refreshSubmit);
|
|
177
|
+
teamSelect.addEventListener('change', function () { syncStates(); refreshSubmit(); });
|
|
131
178
|
|
|
132
179
|
projectSelect.disabled = false;
|
|
133
180
|
if (status) status.style.display = 'none';
|
|
@@ -163,6 +210,15 @@ if (!linearIntegrationInitialized) {
|
|
|
163
210
|
showModal();
|
|
164
211
|
});
|
|
165
212
|
|
|
213
|
+
// Just returned from the OAuth popup: open the modal straight to the
|
|
214
|
+
// project-link step instead of leaving the (display:none) modal closed, so
|
|
215
|
+
// the user doesn't have to reopen the Linear menu by hand. Only reopen when
|
|
216
|
+
// this page's creative matches the one the popup connected for — otherwise a
|
|
217
|
+
// mid-flow navigation would surface the wrong creative's link modal.
|
|
218
|
+
if (consumeReopenAfterConnect(safeSessionStorage(), modal.dataset.creativeId)) {
|
|
219
|
+
showModal();
|
|
220
|
+
}
|
|
221
|
+
|
|
166
222
|
closeBtn?.addEventListener('click', closeModal);
|
|
167
223
|
|
|
168
224
|
modal.addEventListener('click', function (event) {
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Reopen-after-connect intent for the Linear integration modal.
|
|
2
|
+
//
|
|
3
|
+
// When the OAuth popup finishes it posts `linearConnected` and the opener does a
|
|
4
|
+
// full `window.location.reload()` so the server re-renders the modal in its
|
|
5
|
+
// project-linking state (the account now exists). But the modal defaults to
|
|
6
|
+
// `display:none` and nothing re-opens it after the reload — so the connect→link
|
|
7
|
+
// step is invisible and the user has to click the Linear connect button again to
|
|
8
|
+
// reach it.
|
|
9
|
+
//
|
|
10
|
+
// This module persists a one-shot "reopen the modal" flag across that reload.
|
|
11
|
+
// Factored out of the side-effectful collavre_linear.js opener so the behavior
|
|
12
|
+
// is unit-testable (that file is imported only for its window/turbo listeners).
|
|
13
|
+
|
|
14
|
+
export const LINEAR_REOPEN_KEY = 'linearReopenModal';
|
|
15
|
+
|
|
16
|
+
// Read window.sessionStorage defensively. When Web Storage is disabled by policy
|
|
17
|
+
// (packaged WKWebView, a browser blocking storage for the site) the property
|
|
18
|
+
// getter *itself* throws — before any value reaches mark/consume — so guarding
|
|
19
|
+
// only inside those functions is not enough; the access site must be guarded
|
|
20
|
+
// too. Returns null when unavailable so callers never take an uncaught throw.
|
|
21
|
+
export function safeSessionStorage() {
|
|
22
|
+
try {
|
|
23
|
+
return window.sessionStorage;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Unscoped sentinel. Stored when the OAuth creative id is unknown so the reopen
|
|
30
|
+
// still fires (matches any page) — the scoped path stores the id instead. Must
|
|
31
|
+
// be a value no serialized creative id can equal: ids are positive integers and
|
|
32
|
+
// serialize to digit strings, so a non-digit marker never collides. (A previous
|
|
33
|
+
// '1' sentinel collided with creative id 1 — that page's scoped reopen was
|
|
34
|
+
// misread as unscoped and skipped the mismatch guard.)
|
|
35
|
+
const UNSCOPED = '*';
|
|
36
|
+
|
|
37
|
+
// Record that the modal should reopen after the next full-page reload, scoped to
|
|
38
|
+
// the creative the OAuth popup was opened for. The popup posts its own
|
|
39
|
+
// creativeId; storing it lets consume verify the reloaded page still shows that
|
|
40
|
+
// same creative before reopening — otherwise, if the opener navigated to a
|
|
41
|
+
// different creative while the popup was open, the reload would auto-open the
|
|
42
|
+
// wrong creative's link modal and the user could link the project to it.
|
|
43
|
+
// Tolerates a missing or throwing storage (private-mode Safari, packaged
|
|
44
|
+
// WKWebView) — the reopen is a convenience and must never throw inside the
|
|
45
|
+
// postMessage handler.
|
|
46
|
+
export function markReopenAfterConnect(storage, creativeId) {
|
|
47
|
+
const value =
|
|
48
|
+
creativeId != null && String(creativeId) !== '' ? String(creativeId) : UNSCOPED;
|
|
49
|
+
try {
|
|
50
|
+
if (storage) storage.setItem(LINEAR_REOPEN_KEY, value);
|
|
51
|
+
} catch (e) {
|
|
52
|
+
/* storage unavailable — skip the reopen nicety */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Consume the reopen intent: returns true at most once, then clears the flag so
|
|
57
|
+
// a later unrelated reload (e.g. after linking succeeds) does not reopen it. The
|
|
58
|
+
// flag is cleared on the first consume regardless of match, so a stale intent
|
|
59
|
+
// (opener navigated elsewhere) never lingers to reopen the modal on some later
|
|
60
|
+
// visit. Returns true only when the stored creative id matches the current page
|
|
61
|
+
// (or when the legacy unscoped sentinel was stored).
|
|
62
|
+
export function consumeReopenAfterConnect(storage, currentCreativeId) {
|
|
63
|
+
try {
|
|
64
|
+
if (!storage) return false;
|
|
65
|
+
const stored = storage.getItem(LINEAR_REOPEN_KEY);
|
|
66
|
+
if (!stored) return false;
|
|
67
|
+
storage.removeItem(LINEAR_REOPEN_KEY); // one-shot regardless of match
|
|
68
|
+
if (stored === UNSCOPED) return true;
|
|
69
|
+
return String(currentCreativeId == null ? '' : currentCreativeId) === stored;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
/* storage unavailable — treat as no pending reopen */
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
@@ -22,6 +22,26 @@ module CollavreLinear
|
|
|
22
22
|
# sequential FIFO queue that preserves enqueue (receipt) order.
|
|
23
23
|
queue_as :linear_inbound
|
|
24
24
|
|
|
25
|
+
# Auto-recover from transient DB contention. WebhooksController acks Linear
|
|
26
|
+
# with 200 the instant it enqueues this job (ack-before-apply), so Linear
|
|
27
|
+
# never re-delivers a payload once accepted. Without a retry, a transient
|
|
28
|
+
# ActiveRecord::Deadlocked / LockWaitTimeout — which InboundApplier CAN hit,
|
|
29
|
+
# since it takes `with_lock`/`lock!` on the same Creative the OUTBOUND worker
|
|
30
|
+
# locks (see inbound_applier.rb's lock-order note) — would park the event as
|
|
31
|
+
# a failed execution and the local mirror would silently diverge from Linear
|
|
32
|
+
# with no resend to fix it. Retrying is safe: a deadlock/lock-timeout rolls
|
|
33
|
+
# the whole transaction back (no partial apply), and InboundApplier is
|
|
34
|
+
# idempotent (create no-ops on an existing link, unique indexes guard
|
|
35
|
+
# duplicates, disposition checks refuse to clobber newer local edits), so a
|
|
36
|
+
# re-run cannot double-apply. Scope is deliberately narrow — only transient
|
|
37
|
+
# contention retries; a real bug still raises and parks (loud, operator-
|
|
38
|
+
# visible) rather than looping. The wait is kept SHORT (much shorter than the
|
|
39
|
+
# outbound jobs' polynomially_longer) so a rescheduled retry disturbs this
|
|
40
|
+
# FIFO queue's receipt order as little as possible; the applier's own
|
|
41
|
+
# out-of-order tolerance covers the small residual reordering.
|
|
42
|
+
retry_on ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout,
|
|
43
|
+
wait: 2.seconds, attempts: 5
|
|
44
|
+
|
|
25
45
|
def perform(payload)
|
|
26
46
|
unless CollavreLinear.const_defined?(:InboundApplier)
|
|
27
47
|
Rails.logger.info(
|
|
@@ -67,8 +67,12 @@ module CollavreLinear
|
|
|
67
67
|
|
|
68
68
|
# Columns whose change can alter the exported Linear issue (mirrors what
|
|
69
69
|
# CreativeExporter hashes: description -> title/description, sequence ->
|
|
70
|
-
# priority, data -> state/labels) plus parent_id (re-parenting -> parentId)
|
|
71
|
-
|
|
70
|
+
# priority, data -> state/labels) plus parent_id (re-parenting -> parentId)
|
|
71
|
+
# and progress (a leaf reaching 100% -> the project's "done" state, via
|
|
72
|
+
# CreativeExporter.apply_completion!). A non-leaf's rolled-up progress change
|
|
73
|
+
# enqueues a sync too, but apply_completion! no-ops for non-leaves so the
|
|
74
|
+
# content_hash is unchanged and the exporter makes no API call.
|
|
75
|
+
LINEAR_RELEVANT_COLUMNS = %w[description sequence data parent_id progress].freeze
|
|
72
76
|
|
|
73
77
|
# Record — in after_save, where saved_changes is reliable — whether this
|
|
74
78
|
# save touched a Linear-relevant column. A create always counts (its seeded
|
|
@@ -191,11 +191,59 @@ module CollavreLinear
|
|
|
191
191
|
}
|
|
192
192
|
GQL
|
|
193
193
|
|
|
194
|
+
# List workflow states with their owning team id and type so the link modal
|
|
195
|
+
# can offer a "done state" combobox scoped to the chosen team. `type` is
|
|
196
|
+
# Linear's state category (triage/backlog/unstarted/started/completed/
|
|
197
|
+
# canceled) — the picker defaults to the team's "completed" state.
|
|
198
|
+
WORKFLOW_STATES = <<~GQL.freeze
|
|
199
|
+
query WorkflowStates {
|
|
200
|
+
workflowStates(first: 250) {
|
|
201
|
+
nodes {
|
|
202
|
+
id
|
|
203
|
+
name
|
|
204
|
+
type
|
|
205
|
+
position
|
|
206
|
+
team {
|
|
207
|
+
id
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
GQL
|
|
213
|
+
|
|
214
|
+
# Fetch a single issue's current workflow state (for seeding the pre-done
|
|
215
|
+
# snapshot when a leaf is completed with no locally-known Linear state).
|
|
216
|
+
ISSUE_QUERY = <<~GQL.freeze
|
|
217
|
+
query Issue($id: String!) {
|
|
218
|
+
issue(id: $id) {
|
|
219
|
+
id
|
|
220
|
+
state {
|
|
221
|
+
id
|
|
222
|
+
name
|
|
223
|
+
type
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
GQL
|
|
228
|
+
|
|
194
229
|
def initialize(account)
|
|
195
230
|
@account = account
|
|
196
231
|
@endpoint = resolve_endpoint
|
|
197
232
|
end
|
|
198
233
|
|
|
234
|
+
# Fetch a single Linear issue's current workflow state.
|
|
235
|
+
# @param id [String] Linear issue UUID
|
|
236
|
+
# @return [Hash, nil] {"id" =>, "name" =>, "type" =>} (string keys, matching
|
|
237
|
+
# the shape stored under data["linear"]["state"]), or nil when the issue or
|
|
238
|
+
# its state is absent.
|
|
239
|
+
def fetch_issue_state(id)
|
|
240
|
+
data = post!(ISSUE_QUERY, { id: id })
|
|
241
|
+
state = data.dig("issue", "state")
|
|
242
|
+
return nil if state.nil?
|
|
243
|
+
|
|
244
|
+
state.slice("id", "name", "type")
|
|
245
|
+
end
|
|
246
|
+
|
|
199
247
|
# Create a Linear issue.
|
|
200
248
|
# @return [Hash] with :id and :identifier
|
|
201
249
|
def create_issue(team_id:, title:, description: nil, parent_id: nil,
|
|
@@ -315,6 +363,24 @@ module CollavreLinear
|
|
|
315
363
|
end
|
|
316
364
|
end
|
|
317
365
|
|
|
366
|
+
# List workflow states for the "done state" picker, each with its owning
|
|
367
|
+
# team id and category type so the UI can scope states to the selected team
|
|
368
|
+
# and default to the completed one.
|
|
369
|
+
# @return [Array<Hash>] each with :id, :name, :type, :position, :team_id
|
|
370
|
+
def list_workflow_states
|
|
371
|
+
data = post!(WORKFLOW_STATES, {})
|
|
372
|
+
nodes = data.dig("workflowStates", "nodes") || []
|
|
373
|
+
nodes.map do |n|
|
|
374
|
+
{
|
|
375
|
+
id: n["id"],
|
|
376
|
+
name: n["name"],
|
|
377
|
+
type: n["type"],
|
|
378
|
+
position: n["position"],
|
|
379
|
+
team_id: n.dig("team", "id")
|
|
380
|
+
}
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
318
384
|
# Register a webhook with Linear.
|
|
319
385
|
# WebhookCreateInput fields: url, secret, teamId, resourceTypes
|
|
320
386
|
# @return [Hash] with :id
|
|
@@ -332,22 +398,19 @@ module CollavreLinear
|
|
|
332
398
|
# Execute a GraphQL operation and return the `data` hash.
|
|
333
399
|
# Raises Client::Error if the response contains a top-level `errors` key.
|
|
334
400
|
def post!(query, variables)
|
|
335
|
-
|
|
336
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
337
|
-
http.use_ssl = uri.scheme == "https"
|
|
338
|
-
http.open_timeout = 10
|
|
339
|
-
http.read_timeout = 30
|
|
340
|
-
|
|
341
|
-
request = Net::HTTP::Post.new(uri.path.presence || "/")
|
|
342
|
-
request["Content-Type"] = "application/json"
|
|
343
|
-
request["Authorization"] = "Bearer #{fresh_access_token}"
|
|
344
|
-
request.body = { query: query, variables: variables }.to_json
|
|
401
|
+
token = fresh_access_token
|
|
345
402
|
|
|
346
403
|
response =
|
|
347
404
|
begin
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
405
|
+
http_client.post(
|
|
406
|
+
endpoint,
|
|
407
|
+
body: { query: query, variables: variables }.to_json,
|
|
408
|
+
headers: {
|
|
409
|
+
"Content-Type" => "application/json",
|
|
410
|
+
"Authorization" => "Bearer #{token}"
|
|
411
|
+
}
|
|
412
|
+
)
|
|
413
|
+
rescue Collavre::HttpClient::ConnectionError => e
|
|
351
414
|
# Transport-layer failures (connection refused/reset, DNS, TLS,
|
|
352
415
|
# open/read timeouts) raise before any GraphQL response exists, so they
|
|
353
416
|
# bypass the parsed-`errors` path below. Wrap them in Error so the
|
|
@@ -360,7 +423,7 @@ module CollavreLinear
|
|
|
360
423
|
parsed = begin
|
|
361
424
|
JSON.parse(response.body)
|
|
362
425
|
rescue JSON::ParserError
|
|
363
|
-
raise Error, "Linear returned non-JSON response (HTTP #{response.code}): #{response.body.to_s[0, 200]}"
|
|
426
|
+
raise Error, "Linear returned non-JSON response (HTTP #{response.code}) from #{endpoint}: #{response.body.to_s[0, 200]}"
|
|
364
427
|
end
|
|
365
428
|
|
|
366
429
|
if parsed["errors"].present?
|
|
@@ -368,13 +431,19 @@ module CollavreLinear
|
|
|
368
431
|
raise Error, "Linear GraphQL error(s): #{messages}"
|
|
369
432
|
end
|
|
370
433
|
|
|
371
|
-
unless response.
|
|
434
|
+
unless response.success?
|
|
372
435
|
raise Error, "Linear HTTP error: #{response.code} #{response.message}"
|
|
373
436
|
end
|
|
374
437
|
|
|
375
438
|
parsed["data"]
|
|
376
439
|
end
|
|
377
440
|
|
|
441
|
+
# Shared Net::HTTP wrapper preserving Linear's original 10s connect / 30s
|
|
442
|
+
# read timeouts.
|
|
443
|
+
def http_client
|
|
444
|
+
@http_client ||= Collavre::HttpClient.new(open_timeout: 10, read_timeout: 30)
|
|
445
|
+
end
|
|
446
|
+
|
|
378
447
|
# Return a non-expired access token, refreshing via the refresh_token grant
|
|
379
448
|
# when the current token is expiring soon.
|
|
380
449
|
#
|
|
@@ -401,9 +470,32 @@ module CollavreLinear
|
|
|
401
470
|
end
|
|
402
471
|
|
|
403
472
|
def resolve_endpoint
|
|
404
|
-
|
|
405
|
-
Collavre::IntegrationSettings::Resolver
|
|
406
|
-
|
|
473
|
+
configured =
|
|
474
|
+
if defined?(Collavre::IntegrationSettings::Resolver)
|
|
475
|
+
Collavre::IntegrationSettings::Resolver.get(:linear_api_endpoint).presence
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
normalize_endpoint(configured) || DEFAULT_ENDPOINT
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Guard a misconfigured `linear_api_endpoint` override. Linear's GraphQL API
|
|
482
|
+
# lives at the `/graphql` path; an admin who pastes only a base URL
|
|
483
|
+
# (e.g. `https://host:port` or `.../`) would otherwise POST to `/`, which a
|
|
484
|
+
# non-Linear server answers with a 404 ("Cannot POST /"). When the configured
|
|
485
|
+
# value carries no meaningful path, point it at `/graphql`. A value that
|
|
486
|
+
# already specifies a non-root path is left untouched.
|
|
487
|
+
def normalize_endpoint(value)
|
|
488
|
+
return nil if value.blank?
|
|
489
|
+
|
|
490
|
+
uri = URI.parse(value.strip)
|
|
491
|
+
return value if uri.path.present? && uri.path != "/"
|
|
492
|
+
|
|
493
|
+
uri.path = "/graphql"
|
|
494
|
+
uri.to_s
|
|
495
|
+
rescue URI::InvalidURIError
|
|
496
|
+
# Not a parseable URI: return as-is and let the request surface the failure
|
|
497
|
+
# rather than silently rewriting an unrecognizable value.
|
|
498
|
+
value
|
|
407
499
|
end
|
|
408
500
|
|
|
409
501
|
# Convert symbol keys to camelCase strings for GraphQL variables.
|
|
@@ -52,9 +52,70 @@ module CollavreLinear
|
|
|
52
52
|
data: creative.data
|
|
53
53
|
)
|
|
54
54
|
)
|
|
55
|
+
# Fold the completion→state override into the hash so inbound and outbound
|
|
56
|
+
# agree: after an inbound apply advances content_hash via this method, a
|
|
57
|
+
# later outbound sync of a completed leaf must see the SAME state_id it
|
|
58
|
+
# would push. Resolve the governing ProjectLink the same way sync! does.
|
|
59
|
+
apply_completion!(attrs, creative, resolve_project_link_for(creative))
|
|
55
60
|
hash_attrs(attrs, parent_linear_issue_id_for(creative))
|
|
56
61
|
end
|
|
57
62
|
|
|
63
|
+
# Walk self-and-ancestors (nearest first) for the governing ProjectLink.
|
|
64
|
+
# Class-level twin of the instance #resolve_project_link so content_hash_for
|
|
65
|
+
# can apply the same completion override.
|
|
66
|
+
def self.resolve_project_link_for(creative)
|
|
67
|
+
creative.self_and_ancestors.each do |ancestor|
|
|
68
|
+
link = CollavreLinear::ProjectLink.find_by(creative_id: ancestor.id)
|
|
69
|
+
return link if link
|
|
70
|
+
end
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Completion mapping (Collavre → Linear): a LEAF creative at 100% progress
|
|
75
|
+
# exports with its project's configured "done" workflow state. Mutates and
|
|
76
|
+
# returns `attrs`.
|
|
77
|
+
#
|
|
78
|
+
# Guards, each a deliberate no-op:
|
|
79
|
+
# * no project_link / no done_state_id — completion mapping not configured.
|
|
80
|
+
# * creative has active children — only leaves carry independent
|
|
81
|
+
# progress (a parent's progress is a rollup average), so only leaves drive
|
|
82
|
+
# the done state; a parent issue's state follows from its children.
|
|
83
|
+
# * progress < 1.0 — only 100% maps to done. We do NOT
|
|
84
|
+
# blindly clobber the state here; instead, when our record shows the issue
|
|
85
|
+
# still sitting in the done state, we RESTORE the state it held before it
|
|
86
|
+
# was completed (the "un-done" case — see below). Otherwise the last-known
|
|
87
|
+
# Linear state (from data["linear"]) echoes through unchanged.
|
|
88
|
+
#
|
|
89
|
+
# Un-done (Collavre 100% → below): the pre-done state is snapshotted into
|
|
90
|
+
# data["linear"]["state_before_done"] by #capture_state_before_done! at the
|
|
91
|
+
# moment the leaf is pushed to done. On a later drop below 100% we push that
|
|
92
|
+
# snapshot back — but only while data["linear"]["state"] still equals the done
|
|
93
|
+
# state (i.e. our push landed and nobody has since moved the issue). That
|
|
94
|
+
# done-state guard keeps us from fighting a human who moved the issue
|
|
95
|
+
# elsewhere in Linear, and bounds the loop: once the restore echoes back,
|
|
96
|
+
# data["linear"]["state"] is no longer done so this never re-fires.
|
|
97
|
+
#
|
|
98
|
+
# This method stays PURE (no I/O, no mutation) so CreativeExporter.content_hash_for
|
|
99
|
+
# and #sync! compute the SAME state_id — the capture that needs the network is
|
|
100
|
+
# done separately in the sync! path.
|
|
101
|
+
def self.apply_completion!(attrs, creative, project_link)
|
|
102
|
+
done_state_id = project_link&.done_state_id
|
|
103
|
+
return attrs if done_state_id.blank?
|
|
104
|
+
return attrs unless creative.children.active.empty?
|
|
105
|
+
|
|
106
|
+
if creative.progress.to_f >= 1.0
|
|
107
|
+
attrs[:state_id] = done_state_id
|
|
108
|
+
else
|
|
109
|
+
linear = (creative.data || {})["linear"] || {}
|
|
110
|
+
before = linear["state_before_done"]
|
|
111
|
+
current = linear["state"]
|
|
112
|
+
if before.present? && current.is_a?(Hash) && current["id"] == done_state_id
|
|
113
|
+
attrs[:state_id] = before["id"]
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
attrs
|
|
117
|
+
end
|
|
118
|
+
|
|
58
119
|
# Resolve the parent Creative's linked linear_issue_id (or nil when the
|
|
59
120
|
# parent is absent or not itself linked to a Linear issue).
|
|
60
121
|
def self.parent_linear_issue_id_for(creative)
|
|
@@ -84,14 +145,23 @@ module CollavreLinear
|
|
|
84
145
|
# (Its children export as the project's top-level issues; see class docs.)
|
|
85
146
|
return if project_link.creative_id == @creative.id
|
|
86
147
|
|
|
87
|
-
account
|
|
88
|
-
client
|
|
148
|
+
account = project_link.account
|
|
149
|
+
client = Client.new(account)
|
|
150
|
+
issue_link = @creative.linear_issue_links.first
|
|
151
|
+
|
|
152
|
+
# Before a completed leaf's state is overridden to "done", remember the state
|
|
153
|
+
# it is leaving so a later drop below 100% can restore it (outbound un-done).
|
|
154
|
+
# This may query Linear once, so it lives here in the I/O path — NOT in the
|
|
155
|
+
# pure apply_completion!/content_hash_for.
|
|
156
|
+
capture_state_before_done!(client, project_link, issue_link)
|
|
157
|
+
|
|
89
158
|
attrs = FieldMapper.creative_to_issue_attrs(adapt(@creative))
|
|
159
|
+
# Completed leaf → push the project's "done" state; a leaf dropped below 100%
|
|
160
|
+
# → restore its pre-done state (mirrors content_hash_for).
|
|
161
|
+
self.class.apply_completion!(attrs, @creative, project_link)
|
|
90
162
|
parent_id = parent_linear_issue_id
|
|
91
163
|
hash = compute_content_hash(attrs, parent_id)
|
|
92
164
|
|
|
93
|
-
issue_link = @creative.linear_issue_links.first
|
|
94
|
-
|
|
95
165
|
# Cross-project move: the creative was reparented under a DIFFERENT linked
|
|
96
166
|
# root than the one its existing issue belongs to. resolve_project_link now
|
|
97
167
|
# returns the new project, so updating would push the OLD project's issue
|
|
@@ -125,6 +195,57 @@ module CollavreLinear
|
|
|
125
195
|
|
|
126
196
|
private
|
|
127
197
|
|
|
198
|
+
# Snapshot the pre-done Linear workflow state onto
|
|
199
|
+
# data["linear"]["state_before_done"] so a later drop below 100% can restore
|
|
200
|
+
# the issue to it (see apply_completion!'s un-done branch).
|
|
201
|
+
#
|
|
202
|
+
# Acts only for a completed leaf (progress >= 1.0) whose project has a done
|
|
203
|
+
# state configured. Sources the snapshot from the last-known non-done state in
|
|
204
|
+
# data["linear"]["state"]; when none is known (e.g. the leaf reached 100%
|
|
205
|
+
# purely in Collavre before any inbound state sync), queries Linear ONCE to
|
|
206
|
+
# seed it — but only if no snapshot exists yet, so this never re-queries.
|
|
207
|
+
#
|
|
208
|
+
# No-op for: the create path (no Linear issue yet to query), non-leaves,
|
|
209
|
+
# completion mapping off, an already-done known state, and when the snapshot
|
|
210
|
+
# is unchanged. Persists via update_column to avoid the sync observer (this is
|
|
211
|
+
# bookkeeping, not a mapped-field change) and never touches state_id itself.
|
|
212
|
+
def capture_state_before_done!(client, project_link, issue_link)
|
|
213
|
+
done_state_id = project_link.done_state_id
|
|
214
|
+
return if done_state_id.blank?
|
|
215
|
+
return unless @creative.progress.to_f >= 1.0
|
|
216
|
+
return unless @creative.children.active.empty?
|
|
217
|
+
|
|
218
|
+
linear = (@creative.data || {})["linear"] || {}
|
|
219
|
+
current = linear["state"]
|
|
220
|
+
|
|
221
|
+
candidate =
|
|
222
|
+
if current.is_a?(Hash) && current["id"].present? && current["id"] != done_state_id
|
|
223
|
+
current
|
|
224
|
+
elsif linear["state_before_done"].blank? && issue_link&.linear_issue_id.present?
|
|
225
|
+
fetched = client.fetch_issue_state(issue_link.linear_issue_id)
|
|
226
|
+
fetched if fetched.present? && fetched["id"] != done_state_id
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
return if candidate.blank?
|
|
230
|
+
return if candidate == linear["state_before_done"]
|
|
231
|
+
|
|
232
|
+
new_data = (@creative.data || {}).deep_dup
|
|
233
|
+
new_data["linear"] ||= {}
|
|
234
|
+
new_data["linear"]["state_before_done"] = candidate
|
|
235
|
+
# Record our OWN outbound completion into the local state mirror: we are
|
|
236
|
+
# about to push this issue to the done state, but Linear's echo of that push
|
|
237
|
+
# is EchoGuard-suppressed and never arrives, and nothing else writes
|
|
238
|
+
# data["linear"]["state"] outbound. Without this, apply_completion!'s un-done
|
|
239
|
+
# guard (data["linear"]["state"] == done) can NEVER be satisfied on a
|
|
240
|
+
# Collavre-driven completion, so a later drop below 100% would push no state
|
|
241
|
+
# and leave the issue stuck in done. This is the truth — the issue IS now in
|
|
242
|
+
# done because we put it there — and a genuine human move in Linear still
|
|
243
|
+
# overwrites it via the inbound applier, correctly halting the restore.
|
|
244
|
+
new_data["linear"]["state"] = { "id" => done_state_id }
|
|
245
|
+
@creative.data = new_data
|
|
246
|
+
@creative.update_column(:data, new_data)
|
|
247
|
+
end
|
|
248
|
+
|
|
128
249
|
# True when this creative's parent is itself part of the exported subtree
|
|
129
250
|
# (it resolves the governing ProjectLink) but has not yet been exported to
|
|
130
251
|
# Linear (no linear_issue_id). The parent MUST land first so this child can
|
|
@@ -97,6 +97,10 @@ module CollavreLinear
|
|
|
97
97
|
creative.skip_linear_sync = true
|
|
98
98
|
merge_linear_data!(creative, attrs[:data_linear])
|
|
99
99
|
creative.sequence = attrs[:sequence]
|
|
100
|
+
# Completion mapping (Linear → Collavre): a freshly-imported issue that is
|
|
101
|
+
# already in the project's "done" state marks the (new, always-leaf)
|
|
102
|
+
# creative 100%.
|
|
103
|
+
reconcile_leaf_progress!(creative, project_link)
|
|
100
104
|
creative.save!
|
|
101
105
|
|
|
102
106
|
IssueLink.create!(
|
|
@@ -208,6 +212,17 @@ module CollavreLinear
|
|
|
208
212
|
end
|
|
209
213
|
merge_linear_data!(creative, attrs[:data_linear])
|
|
210
214
|
|
|
215
|
+
# Completion mapping (Linear → Collavre): only reconcile when the state
|
|
216
|
+
# field is actually part of this update (or updatedFrom is absent). A
|
|
217
|
+
# title-only edit must not touch the leaf's progress.
|
|
218
|
+
# Linear keys the state change in updatedFrom by its scalar FK column
|
|
219
|
+
# `stateId` (mirrors `parentId`/`projectId`/`priority` elsewhere), NOT a
|
|
220
|
+
# nested `state` object — matching "state" here never fired on real
|
|
221
|
+
# webhooks, silently disabling done→100% on the normal update path.
|
|
222
|
+
if changed.nil? || changed.include?("stateId")
|
|
223
|
+
reconcile_leaf_progress!(creative, link.project_link)
|
|
224
|
+
end
|
|
225
|
+
|
|
211
226
|
# Reparent: when the payload's Linear parent differs from what the link
|
|
212
227
|
# last recorded, move the Creative under the corresponding linked parent
|
|
213
228
|
# and track the new parent_issue_id. Without this the tree stays under the
|
|
@@ -548,6 +563,37 @@ module CollavreLinear
|
|
|
548
563
|
attrs[:title].presence || ""
|
|
549
564
|
end
|
|
550
565
|
|
|
566
|
+
# Completion mapping (Linear → Collavre). Reflect whether the inbound issue
|
|
567
|
+
# is in the project's configured "done" workflow state onto the LEAF
|
|
568
|
+
# creative's progress. Sets progress in memory; the caller's save! persists
|
|
569
|
+
# it under skip_linear_sync (no echo back to Linear).
|
|
570
|
+
#
|
|
571
|
+
# Guards (deliberate no-ops):
|
|
572
|
+
# * done_state_id blank — completion mapping not configured.
|
|
573
|
+
# * origin-linked creative — its progress delegates to the origin and
|
|
574
|
+
# a direct write is validation-forbidden; leave it alone.
|
|
575
|
+
# * creative has active children — only leaves carry independent progress
|
|
576
|
+
# (a parent's is a rollup average driven by its children).
|
|
577
|
+
#
|
|
578
|
+
# Transitions:
|
|
579
|
+
# * state == done_state_id → progress 1.0 (100%).
|
|
580
|
+
# * state != done_state_id AND was 1.0 → progress 0.0 (un-complete). A
|
|
581
|
+
# sub-100% local progress is PRESERVED — the done<->100% mapping is
|
|
582
|
+
# binary, so a non-done state must not clobber partial Collavre progress.
|
|
583
|
+
def reconcile_leaf_progress!(creative, project_link)
|
|
584
|
+
done_state_id = project_link&.done_state_id
|
|
585
|
+
return if done_state_id.blank?
|
|
586
|
+
return if creative.respond_to?(:origin_id) && creative.origin_id.present?
|
|
587
|
+
return unless creative.children.active.empty?
|
|
588
|
+
|
|
589
|
+
state_id = @data.dig("state", "id")
|
|
590
|
+
if state_id.present? && state_id == done_state_id
|
|
591
|
+
creative.progress = 1.0 if creative.progress.to_f < 1.0
|
|
592
|
+
elsif creative.progress.to_f >= 1.0
|
|
593
|
+
creative.progress = 0.0
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
551
597
|
def merge_linear_data!(creative, data_linear)
|
|
552
598
|
return if data_linear.blank?
|
|
553
599
|
|
|
@@ -50,13 +50,33 @@
|
|
|
50
50
|
</div>
|
|
51
51
|
|
|
52
52
|
<script>
|
|
53
|
-
|
|
53
|
+
// Tell the opener the account is connected so it advances straight to the
|
|
54
|
+
// project-link step. The opener also closes this popup (window.close() below
|
|
55
|
+
// is unreliable after the cross-origin OAuth round trip and in the desktop
|
|
56
|
+
// WebView, which is why the "Close" button used to look dead).
|
|
57
|
+
function notifyOpener() {
|
|
54
58
|
try {
|
|
55
|
-
if (window.opener) {
|
|
56
|
-
|
|
59
|
+
if (window.opener && !window.opener.closed) {
|
|
60
|
+
// Include the creative this popup was opened for so the opener only
|
|
61
|
+
// reopens the modal when it's still showing that same creative — not
|
|
62
|
+
// whatever creative it may have navigated to while the popup was open.
|
|
63
|
+
var wizard = document.getElementById('linear-wizard');
|
|
64
|
+
var creativeId = wizard ? wizard.dataset.creativeId : '';
|
|
65
|
+
window.opener.postMessage(
|
|
66
|
+
{ type: 'linearConnected', creativeId: creativeId },
|
|
67
|
+
window.location.origin
|
|
68
|
+
);
|
|
57
69
|
}
|
|
58
70
|
} catch (e) {}
|
|
59
|
-
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Auto-advance on load: no click required — the opener reloads and jumps to
|
|
74
|
+
// the project settings ("link a project") step.
|
|
75
|
+
notifyOpener();
|
|
76
|
+
|
|
77
|
+
document.getElementById('close-btn').addEventListener('click', function() {
|
|
78
|
+
notifyOpener();
|
|
79
|
+
try { window.close(); } catch (e) {}
|
|
60
80
|
});
|
|
61
81
|
</script>
|
|
62
82
|
</body>
|
|
@@ -159,6 +159,23 @@
|
|
|
159
159
|
style: "width:100%;padding:0.4em 0.6em;border:1px solid var(--color-border);border-radius:4px;" %>
|
|
160
160
|
</div>
|
|
161
161
|
|
|
162
|
+
<%# Done-state combobox: which Linear workflow state marks an issue as
|
|
163
|
+
"완료". JS scopes the options to the chosen team and defaults to that
|
|
164
|
+
team's Completed state. Optional — a team with no completed state
|
|
165
|
+
leaves it blank and completion mapping stays off. %>
|
|
166
|
+
<div style="margin-bottom:1em;">
|
|
167
|
+
<%= f.label :done_state_id, t("collavre_linear.integration.done_state_label"),
|
|
168
|
+
style: "display:block;margin-bottom:0.25em;font-weight:600;" %>
|
|
169
|
+
<%= f.select :done_state_id,
|
|
170
|
+
[],
|
|
171
|
+
{ include_blank: t("collavre_linear.integration.done_state_placeholder") },
|
|
172
|
+
disabled: true,
|
|
173
|
+
style: "width:100%;padding:0.4em 0.6em;border:1px solid var(--color-border);border-radius:4px;" %>
|
|
174
|
+
<p style="margin:0.25em 0 0;color:var(--text-muted);font-size:0.85em;">
|
|
175
|
+
<%= t("collavre_linear.integration.done_state_help") %>
|
|
176
|
+
</p>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
162
179
|
<%= f.submit t("collavre_linear.integration.link_button"),
|
|
163
180
|
class: "btn btn-primary", disabled: true %>
|
|
164
181
|
<% end %>
|
data/config/locales/en.yml
CHANGED
|
@@ -17,6 +17,9 @@ en:
|
|
|
17
17
|
project_id_label: "Project"
|
|
18
18
|
project_select_placeholder: "Select a project…"
|
|
19
19
|
team_select_placeholder: "Select a team…"
|
|
20
|
+
done_state_label: "Done state"
|
|
21
|
+
done_state_placeholder: "Select a done state…"
|
|
22
|
+
done_state_help: "When a Linear issue reaches this state its matching leaf creative is marked 100% — and a leaf creative reaching 100% moves its Linear issue to this state."
|
|
20
23
|
options_loading: "Loading projects and teams…"
|
|
21
24
|
options_failed: "Couldn't load your Linear projects and teams. Please try again."
|
|
22
25
|
no_projects: "No projects found in your Linear workspace."
|
data/config/locales/ko.yml
CHANGED
|
@@ -17,6 +17,9 @@ ko:
|
|
|
17
17
|
project_id_label: "프로젝트"
|
|
18
18
|
project_select_placeholder: "프로젝트 선택…"
|
|
19
19
|
team_select_placeholder: "팀 선택…"
|
|
20
|
+
done_state_label: "완료 상태"
|
|
21
|
+
done_state_placeholder: "완료 상태 선택…"
|
|
22
|
+
done_state_help: "Linear 이슈가 이 상태가 되면 대응되는 leaf 크리에이티브가 100%로 처리되고, 반대로 leaf 크리에이티브가 100%가 되면 Linear 이슈가 이 상태로 이동합니다."
|
|
20
23
|
options_loading: "프로젝트와 팀을 불러오는 중…"
|
|
21
24
|
options_failed: "Linear 프로젝트와 팀을 불러오지 못했습니다. 다시 시도해 주세요."
|
|
22
25
|
no_projects: "Linear 워크스페이스에 프로젝트가 없습니다."
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
class AddDoneStateIdToLinearProjectLinks < ActiveRecord::Migration[8.0]
|
|
2
|
+
# The Linear workflow-state UUID that represents "done" for this linked
|
|
3
|
+
# project. Chosen by the admin (combobox, default "Completed") at link time.
|
|
4
|
+
# Nullable: links created before this feature — or teams with no completed
|
|
5
|
+
# state — simply have no completion mapping (the sync no-ops both ways).
|
|
6
|
+
def change
|
|
7
|
+
add_column :linear_project_links, :done_state_id, :string
|
|
8
|
+
end
|
|
9
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: collavre_linear
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Collavre
|
|
@@ -49,7 +49,9 @@ files:
|
|
|
49
49
|
- app/controllers/collavre_linear/auth_controller.rb
|
|
50
50
|
- app/controllers/collavre_linear/creatives/integrations_controller.rb
|
|
51
51
|
- app/controllers/collavre_linear/webhooks_controller.rb
|
|
52
|
+
- app/javascript/__tests__/linear_modal_reopen.test.js
|
|
52
53
|
- app/javascript/collavre_linear.js
|
|
54
|
+
- app/javascript/linear_modal_reopen.js
|
|
53
55
|
- app/jobs/collavre_linear/inbound_apply_job.rb
|
|
54
56
|
- app/jobs/collavre_linear/outbound_archive_job.rb
|
|
55
57
|
- app/jobs/collavre_linear/outbound_comment_delete_job.rb
|
|
@@ -86,6 +88,7 @@ files:
|
|
|
86
88
|
- db/migrate/20260701000006_add_lookup_indexes_to_linear_project_links.rb
|
|
87
89
|
- db/migrate/20260701000007_enforce_one_project_link_per_creative.rb
|
|
88
90
|
- db/migrate/20260701000008_allow_null_webhook_secret_on_linear_project_links.rb
|
|
91
|
+
- db/migrate/20260704000000_add_done_state_id_to_linear_project_links.rb
|
|
89
92
|
- lib/collavre_linear.rb
|
|
90
93
|
- lib/collavre_linear/engine.rb
|
|
91
94
|
- lib/collavre_linear/version.rb
|