collavre_linear 0.2.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: f91e7ebe8a6419a4b13d66c1d3cd919520bbef1c617981747f0008fafef63377
4
- data.tar.gz: a960ab7bd872ba996974b5f02c4741e4d6285f5f699be6258fc1979ccf42dd38
3
+ metadata.gz: d8004796cae3abe1389345fbd85959f2390e8d8930768c86045ba202ca94c5e1
4
+ data.tar.gz: 0e6f414178db0ee5513f238cac0fabf75a44b4eea80a1f7142ce94487bbff798
5
5
  SHA512:
6
- metadata.gz: b30d822da909ec236b3af65b015b846550c9128c1989169a1014cbfecf5d8a595c833732b26d4a9eaa7175da3042e05672a4ea0dca81ac92ad1524d9ec64c7f4
7
- data.tar.gz: 4e6e4b2cf5ad7dbd39cffb15bbebc959f80ae8db047c019fd013f8625a2bd4f4fcb24ec57f1817f515432beaf740304697ea16ddf63e3d82c7d2236e577fe9e9
6
+ metadata.gz: 258031f64f076de07891357445af346b6bc10c75d43fb802d2a749eb1c7069bc4d81ca64a66366b0875b2f971c8bd986c223fb133ca47dcbc0ed9c86d936cf37
7
+ data.tar.gz: 07b41024a06d0d5074a43685a2dfbab697f92990fb6100849fcf893c38159665c553254bdaca8f40f5ea209b632dc6b880d583a133047a80a47cb0e89d2c25c0
@@ -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
  });
@@ -191,6 +210,15 @@ if (!linearIntegrationInitialized) {
191
210
  showModal();
192
211
  });
193
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
+
194
222
  closeBtn?.addEventListener('click', closeModal);
195
223
 
196
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
+ }
@@ -423,7 +423,7 @@ module CollavreLinear
423
423
  parsed = begin
424
424
  JSON.parse(response.body)
425
425
  rescue JSON::ParserError
426
- 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]}"
427
427
  end
428
428
 
429
429
  if parsed["errors"].present?
@@ -470,9 +470,32 @@ module CollavreLinear
470
470
  end
471
471
 
472
472
  def resolve_endpoint
473
- if defined?(Collavre::IntegrationSettings::Resolver)
474
- Collavre::IntegrationSettings::Resolver.get(:linear_api_endpoint).presence
475
- 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
476
499
  end
477
500
 
478
501
  # Convert symbol keys to camelCase strings for GraphQL variables.
@@ -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>
@@ -1,3 +1,3 @@
1
1
  module CollavreLinear
2
- VERSION = "0.2.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.2.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