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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5cc482eb53e316ac259f9565368c50a59cbd1dce01be3b74100c41b239919731
4
- data.tar.gz: cccca601d9c57cd0d45ee603d50b46663d6d3daac3373e93a4b4e3b9b59348b1
3
+ metadata.gz: d8004796cae3abe1389345fbd85959f2390e8d8930768c86045ba202ca94c5e1
4
+ data.tar.gz: 0e6f414178db0ee5513f238cac0fabf75a44b4eea80a1f7142ce94487bbff798
5
5
  SHA512:
6
- metadata.gz: e4a88144150cd9d67ff55433fde009579709cb572cdc08a7cd5209c6d187394917a0efdcd8bbd6a73d60e85fc46ae5d21e8ac731b015b0a0973819e73b51cd61
7
- data.tar.gz: 28ee1fc888af6a9a86fd2b18f30883668d273111ad01912a14cc73e0eb598ae412c0efd0733e850013e4da2e81bd4b888b564e74e0e654619df849457d13ec2c
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: { teams: client.list_teams, projects: client.list_projects }
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.now.to_f * 1000).to_i
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 when it closes.
25
- // Reload so the modal re-renders in the connected (link-a-project) state.
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
- LINEAR_RELEVANT_COLUMNS = %w[description sequence data parent_id].freeze
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
- uri = URI.parse(endpoint)
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
- http.request(request)
349
- rescue SocketError, SystemCallError, Timeout::Error, IOError,
350
- OpenSSL::SSL::SSLError => e
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.is_a?(Net::HTTPSuccess)
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
- if defined?(Collavre::IntegrationSettings::Resolver)
405
- Collavre::IntegrationSettings::Resolver.get(:linear_api_endpoint).presence
406
- end || DEFAULT_ENDPOINT
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 = project_link.account
88
- client = Client.new(account)
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
- document.getElementById('close-btn').addEventListener('click', function() {
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
- window.opener.postMessage({ type: 'linearConnected' }, window.location.origin);
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
- window.close();
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 %>
@@ -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."
@@ -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
@@ -1,3 +1,3 @@
1
1
  module CollavreLinear
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.1"
3
3
  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.0
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