collavre_github 0.6.0 → 0.6.2

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: 10db15f8f1d4877e2b2b8c3c52b6a7bc6b906f742b6ef22d72c5edcdee89ee22
4
- data.tar.gz: cc55e2500dafc1b6d19e59f6cb003b966de5ec9394009def30525c0a5b3c00b3
3
+ metadata.gz: 3c290ca646f38dca20fc5579da040eb6afe437b763298b130b4cfdf33528b53b
4
+ data.tar.gz: 42b03d3de6dc5b1e7c3150b0acbf42400d1057f0c3dc0281ede2e91f13e9e70e
5
5
  SHA512:
6
- metadata.gz: ce8c29467f23b907a04ef532588d059ca1e7fa2f838e600fd730ba04becc5aba205f2669828a3ac520cd7ace29f29bfb0cc9eec10da776aa5f55693c42860143
7
- data.tar.gz: 456701d9b38e6b8498064cede91e6931d2abea3c1d9671d73e6a370aa8e251d207e69b0d5000aac29dd5db597fbb4d1cb28b556bbed02b117dd813ef54f75c6d
6
+ metadata.gz: d5a7fd65770ca4b23ad127dacef54e615300a1fa7b695ff2e7cbb071cef667457fec581d469d893c5a7c3e41be844fe24f9f32ee4c42034738fbb46b08c41448
7
+ data.tar.gz: 0c011cec94b20d833d185df0eb5666309f68814700a55a94c1ec41fe1be5d6105639c0b79f6b4c577275a81c36a047dbb17c71cb3272d7559a96ec93453804f8
@@ -0,0 +1,57 @@
1
+ import { deriveConnectState, shouldShowConnectNext } from '../github_wizard_state.js';
2
+
3
+ describe('deriveConnectState', () => {
4
+ test('connected user with selected repos: connected + existing', () => {
5
+ expect(deriveConnectState({ connected: true, selected_repositories: ['a/b'] }))
6
+ .toEqual({ userConnected: true, hasExistingIntegration: true });
7
+ });
8
+
9
+ test('connected user with no selected repos: connected, no existing', () => {
10
+ expect(deriveConnectState({ connected: true, selected_repositories: [] }))
11
+ .toEqual({ userConnected: true, hasExistingIntegration: false });
12
+ });
13
+
14
+ test('connected user ignores all_repositories for existing flag', () => {
15
+ // A connected user's existing state comes from their own selection,
16
+ // never from other members' all_repositories.
17
+ expect(deriveConnectState({ connected: true, selected_repositories: [], all_repositories: ['x/y', 'x/z'] }))
18
+ .toEqual({ userConnected: true, hasExistingIntegration: false });
19
+ });
20
+
21
+ test('unconnected user with repos linked by others: not connected but existing', () => {
22
+ expect(deriveConnectState({ connected: false, all_repositories: ['x/y', 'x/z', 'x/w'] }))
23
+ .toEqual({ userConnected: false, hasExistingIntegration: true });
24
+ });
25
+
26
+ test('unconnected user with no repos: not connected, no existing', () => {
27
+ expect(deriveConnectState({ connected: false, all_repositories: [] }))
28
+ .toEqual({ userConnected: false, hasExistingIntegration: false });
29
+ });
30
+
31
+ test('tolerates missing/empty payload', () => {
32
+ expect(deriveConnectState()).toEqual({ userConnected: false, hasExistingIntegration: false });
33
+ expect(deriveConnectState({})).toEqual({ userConnected: false, hasExistingIntegration: false });
34
+ });
35
+ });
36
+
37
+ describe('shouldShowConnectNext', () => {
38
+ test('shows Next only when the current user is connected AND repos exist', () => {
39
+ expect(shouldShowConnectNext({ hasExistingIntegration: true, userConnected: true })).toBe(true);
40
+ });
41
+
42
+ test('hides Next for an unconnected user even when repos exist (regression)', () => {
43
+ // Repos linked by other members must not let an unauthenticated user
44
+ // advance into an empty organization list — they must log in first.
45
+ expect(shouldShowConnectNext({ hasExistingIntegration: true, userConnected: false })).toBe(false);
46
+ });
47
+
48
+ test('hides Next when there is no existing integration', () => {
49
+ expect(shouldShowConnectNext({ hasExistingIntegration: false, userConnected: true })).toBe(false);
50
+ expect(shouldShowConnectNext({ hasExistingIntegration: false, userConnected: false })).toBe(false);
51
+ });
52
+
53
+ test('tolerates missing state', () => {
54
+ expect(shouldShowConnectNext()).toBe(false);
55
+ expect(shouldShowConnectNext({})).toBe(false);
56
+ });
57
+ });
@@ -1,4 +1,6 @@
1
1
  import { csrfToken, showError, clearError, updateStepVisibility, openOAuthPopup, fetchWithCsrf, setupModalClose } from 'collavre/modules/integration_wizard';
2
+ import { alertDialog, confirmDialog } from 'collavre/lib/utils/dialog';
3
+ import { deriveConnectState, shouldShowConnectNext } from './github_wizard_state.js';
2
4
 
3
5
  let githubIntegrationInitialized = false;
4
6
 
@@ -50,6 +52,10 @@ if (!githubIntegrationInitialized) {
50
52
  let webhookDetails = {};
51
53
 
52
54
  let hasExistingIntegration = false;
55
+ // Whether the *current* user has their own connected GitHub account.
56
+ // hasExistingIntegration can be true from other members' linked repos while
57
+ // this user is still unauthenticated, so the two must be tracked separately.
58
+ let userConnected = false;
53
59
  let selectedReposForDeletion = new Set();
54
60
 
55
61
  const markdownSyncList = document.getElementById('github-markdown-sync-list');
@@ -63,6 +69,7 @@ if (!githubIntegrationInitialized) {
63
69
  webhookDetails = {};
64
70
 
65
71
  hasExistingIntegration = false;
72
+ userConnected = false;
66
73
  selectedReposForDeletion = new Set();
67
74
  statusEl.textContent = '';
68
75
  errorEl.style.display = 'none';
@@ -100,7 +107,10 @@ if (!githubIntegrationInitialized) {
100
107
 
101
108
  if (currentStep === 'connect') {
102
109
  prevBtn.style.display = 'none';
103
- if (hasExistingIntegration) {
110
+ // Only surface "Next" when the current user is actually connected. When
111
+ // existing repos come solely from other members, the user must log in
112
+ // first — otherwise Next leads to an empty (0 org) dead end.
113
+ if (shouldShowConnectNext({ hasExistingIntegration, userConnected })) {
104
114
  nextBtn.style.display = 'block';
105
115
  nextBtn.disabled = false;
106
116
  } else {
@@ -197,18 +207,19 @@ if (!githubIntegrationInitialized) {
197
207
  fetch(`/github/creatives/${creativeId}/integration`, { headers: { Accept: 'application/json' } })
198
208
  .then(function (response) { return response.json(); })
199
209
  .then(function (data) {
210
+ var connectState = deriveConnectState(data);
211
+ userConnected = connectState.userConnected;
212
+ hasExistingIntegration = connectState.hasExistingIntegration;
213
+
200
214
  if (!data.connected) {
201
- var allRepos = data.all_repositories || [];
202
- if (allRepos.length > 0) {
215
+ if (hasExistingIntegration) {
203
216
  // Other users have linked repositories to this creative
204
217
  statusEl.textContent = existingMessage;
205
- hasExistingIntegration = true;
206
- renderExistingConnections(allRepos, true);
218
+ renderExistingConnections(data.all_repositories || [], true);
207
219
  if (connectMessage) connectMessage.style.display = 'none';
208
220
  if (loginBtn) loginBtn.style.display = 'inline-block';
209
221
  } else {
210
222
  statusEl.textContent = '';
211
- hasExistingIntegration = false;
212
223
  renderExistingConnections([]);
213
224
  if (connectMessage) connectMessage.style.display = '';
214
225
  if (loginBtn) loginBtn.style.display = 'inline-block';
@@ -229,8 +240,7 @@ if (!githubIntegrationInitialized) {
229
240
  }
230
241
  });
231
242
 
232
- hasExistingIntegration = selectedRepos.size > 0;
233
-
243
+ // hasExistingIntegration was derived above from selected_repositories.
234
244
  if (loginBtn) loginBtn.style.display = 'none';
235
245
  renderExistingConnections(Array.from(selectedRepos));
236
246
 
@@ -513,7 +523,7 @@ if (!githubIntegrationInitialized) {
513
523
 
514
524
 
515
525
  updateSummary();
516
- alert(modal.dataset.successMessage);
526
+ alertDialog(modal.dataset.successMessage);
517
527
  })
518
528
  .catch(function () {
519
529
  showError('연동 정보를 저장하지 못했습니다.');
@@ -523,7 +533,7 @@ if (!githubIntegrationInitialized) {
523
533
  openBtn.addEventListener('click', function () {
524
534
  creativeId = openBtn.dataset.creativeId;
525
535
  if (!creativeId) {
526
- alert(modal.dataset.noCreative);
536
+ alertDialog(modal.dataset.noCreative);
527
537
  return;
528
538
  }
529
539
  resetWizard();
@@ -598,9 +608,9 @@ if (!githubIntegrationInitialized) {
598
608
  });
599
609
  });
600
610
 
601
- deleteBtn?.addEventListener('click', function () {
611
+ deleteBtn?.addEventListener('click', async function () {
602
612
  if (!creativeId) {
603
- alert(modal.dataset.noCreative);
613
+ alertDialog(modal.dataset.noCreative);
604
614
  return;
605
615
  }
606
616
  clearError();
@@ -609,7 +619,7 @@ if (!githubIntegrationInitialized) {
609
619
  showError(deleteSelectWarning);
610
620
  return;
611
621
  }
612
- if (!window.confirm(deleteConfirm)) return;
622
+ if (!(await confirmDialog(deleteConfirm, { danger: true }))) return;
613
623
 
614
624
  fetch(`/github/creatives/${creativeId}/integration`, {
615
625
  method: 'DELETE',
@@ -648,7 +658,7 @@ if (!githubIntegrationInitialized) {
648
658
  });
649
659
  });
650
660
 
651
- resyncBtn?.addEventListener('click', function () {
661
+ resyncBtn?.addEventListener('click', async function () {
652
662
  if (!creativeId) return;
653
663
  clearError();
654
664
  const checkboxes = existingList ? existingList.querySelectorAll('.github-existing-repo-checkbox:checked') : [];
@@ -657,7 +667,7 @@ if (!githubIntegrationInitialized) {
657
667
  showError(resyncSelectWarning);
658
668
  return;
659
669
  }
660
- if (!window.confirm(resyncConfirm)) return;
670
+ if (!(await confirmDialog(resyncConfirm))) return;
661
671
 
662
672
  resyncBtn.disabled = true;
663
673
  let completed = 0;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pure-logic helpers for the GitHub integration wizard's connect step.
3
+ * Extracted so the "can this user advance past the connect step" decision
4
+ * can be unit-tested without the full DOM wizard.
5
+ */
6
+
7
+ /**
8
+ * Derive connect-step flags from the integration status payload.
9
+ *
10
+ * `userConnected` reflects whether the *current* user has their own linked
11
+ * GitHub account. `hasExistingIntegration` reflects whether the creative
12
+ * already has repositories linked — by this user when connected, or by *any*
13
+ * member (all_repositories) when the current user is not yet connected.
14
+ *
15
+ * @param {{connected?:boolean, all_repositories?:string[], selected_repositories?:string[]}} status
16
+ * @returns {{userConnected:boolean, hasExistingIntegration:boolean}}
17
+ */
18
+ export function deriveConnectState(status) {
19
+ const s = status || {};
20
+ const userConnected = !!s.connected;
21
+ const repos = userConnected ? s.selected_repositories : s.all_repositories;
22
+ const hasExistingIntegration = (repos || []).length > 0;
23
+ return { userConnected, hasExistingIntegration };
24
+ }
25
+
26
+ /**
27
+ * Whether the connect step's "Next" button should be shown.
28
+ *
29
+ * Existing repositories linked by *other* members must not let an
30
+ * unauthenticated user advance: organization lookup requires the user's own
31
+ * connected GitHub account, so advancing would dead-end on an empty
32
+ * organization list. Such a user must log in first.
33
+ *
34
+ * @param {{hasExistingIntegration?:boolean, userConnected?:boolean}} state
35
+ * @returns {boolean}
36
+ */
37
+ export function shouldShowConnectNext(state) {
38
+ const s = state || {};
39
+ return !!(s.hasExistingIntegration && s.userConnected);
40
+ }
@@ -6,6 +6,14 @@ module CollavreGithub
6
6
  belongs_to :github_account, class_name: "CollavreGithub::Account"
7
7
  belongs_to :markdown_root_creative, class_name: "Collavre::Creative", optional: true
8
8
 
9
+ # HMAC secret GitHub signs webhook deliveries with. Encrypted at rest so a
10
+ # DB/backup leak can't forge webhooks (mirrors CollavreGithub::Account#token
11
+ # and CollavreLinear::ProjectLink#webhook_secret). Non-deterministic: the
12
+ # value is only ever read back decrypted (WebhooksController, provisioner),
13
+ # never queried by ciphertext. A backfill migration re-encrypts pre-existing
14
+ # plaintext rows.
15
+ encrypts :webhook_secret, deterministic: false
16
+
9
17
  validates :repository_full_name, presence: true
10
18
  validates :webhook_secret, presence: true
11
19
 
@@ -73,7 +73,7 @@ module CollavreGithub
73
73
  "sha" => @tree_cache[path]
74
74
  )
75
75
  source.delete("rendered_html")
76
- creative.skip_github_validation = true
76
+ creative.skip_read_only_source_validation = true
77
77
  creative.update!(data: creative.data.merge("source" => source))
78
78
 
79
79
  processed, blobs = processor.process(content, path)
@@ -107,7 +107,7 @@ module CollavreGithub
107
107
  }
108
108
  }
109
109
  )
110
- creative.skip_github_validation = true
110
+ creative.skip_read_only_source_validation = true
111
111
  creative.save!
112
112
  @synced_creatives[path] = creative
113
113
 
@@ -130,15 +130,18 @@ module CollavreGithub
130
130
  branch == @link.markdown_sync_branch
131
131
  end
132
132
 
133
- # Load all synced creatives for this repository link, indexed by path
133
+ # Load all synced creatives for this repository link, indexed by path.
134
+ #
135
+ # The `->`/`->>` JSON operators are portable: both PostgreSQL and modern
136
+ # SQLite (bundled by the `sqlite3` gem used here) support them, so a
137
+ # single expression works on both adapters without an
138
+ # `adapter_name == "PostgreSQL"` branch. Wrapping in `CAST(... AS
139
+ # INTEGER)` (ANSI SQL, supported by both) avoids relying on Postgres's
140
+ # `::integer` cast syntax, which SQLite doesn't understand.
134
141
  def load_synced_creatives
135
- scope = Collavre::Creative.where(archived_at: nil)
136
-
137
- if scope.connection.adapter_name == "PostgreSQL"
138
- scope = scope.where("(data->'source'->>'repository_link_id')::integer = ?", @link.id)
139
- else
140
- scope = scope.where("json_extract(data, '$.source.repository_link_id') = ?", @link.id)
141
- end
142
+ scope = Collavre::Creative
143
+ .where(archived_at: nil)
144
+ .where("CAST(data -> 'source' ->> 'repository_link_id' AS INTEGER) = ?", @link.id)
142
145
 
143
146
  scope.each_with_object({}) { |c, h| h[c.data.dig("source", "path")] = c }
144
147
  end
@@ -216,7 +219,7 @@ module CollavreGithub
216
219
  }
217
220
  }
218
221
  )
219
- dir_creative.skip_github_validation = true
222
+ dir_creative.skip_read_only_source_validation = true
220
223
  dir_creative.save!
221
224
  @synced_creatives[dir_path] = dir_creative
222
225
  new_dirs << dir_creative
@@ -63,7 +63,7 @@ module CollavreGithub
63
63
  }
64
64
  }
65
65
  )
66
- creative.skip_github_validation = true
66
+ creative.skip_read_only_source_validation = true
67
67
  creative.save!
68
68
  file_creatives << creative
69
69
  created << creative
@@ -114,7 +114,7 @@ module CollavreGithub
114
114
  }
115
115
  }
116
116
  )
117
- creative.skip_github_validation = true
117
+ creative.skip_read_only_source_validation = true
118
118
  creative.save!
119
119
  creative
120
120
  end
@@ -175,7 +175,7 @@ module CollavreGithub
175
175
  }
176
176
  }
177
177
  )
178
- dir_creative.skip_github_validation = true
178
+ dir_creative.skip_read_only_source_validation = true
179
179
  dir_creative.save!
180
180
  dir_map[current_path] = dir_creative
181
181
  created << dir_creative
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GitHub-synced Markdown creatives are read-only in Collavre: their description
4
+ # is owned by the upstream repository and must not be edited in-app. Register
5
+ # the source type into the core read-only-source registry so core enforces the
6
+ # read-only behavior (validation, attachment embedding, tree write flags)
7
+ # without naming GitHub. Runs on boot and every reload so the registration
8
+ # survives Zeitwerk clearing Collavre::Creative's class state.
9
+ Rails.application.config.to_prepare do
10
+ if defined?(Collavre::Creative)
11
+ Collavre::Creative.register_read_only_source("github_markdown")
12
+ end
13
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Re-encrypt pre-existing plaintext webhook_secret values in place now that
4
+ # CollavreGithub::RepositoryLink declares `encrypts :webhook_secret`. Reads the
5
+ # raw column with SQL (bypassing the model so we get plaintext, not a decrypt
6
+ # attempt), skips rows already in Active Record Encryption's JSON envelope, and
7
+ # writes the encrypted payload back with the model's own encryptor so the format
8
+ # matches runtime reads. `support_unencrypted_data = true` keeps unmigrated rows
9
+ # readable, so this is safe to run without downtime.
10
+ class EncryptGithubRepositoryLinkWebhookSecrets < ActiveRecord::Migration[8.1]
11
+ def up
12
+ say_with_time "Encrypting GitHub repository link webhook secrets" do
13
+ encrypt_column(CollavreGithub::RepositoryLink, :webhook_secret)
14
+ end
15
+ end
16
+
17
+ def down
18
+ # No-op: `encrypts` decrypts transparently, and support_unencrypted_data
19
+ # keeps any remaining plaintext readable.
20
+ end
21
+
22
+ private
23
+
24
+ def encrypt_column(model, attribute)
25
+ table_name = model.table_name
26
+ connection = ActiveRecord::Base.connection
27
+
28
+ rows = connection.select_all(
29
+ "SELECT id, #{attribute} FROM #{table_name} WHERE #{attribute} IS NOT NULL"
30
+ ).to_a
31
+
32
+ encryptor = model.type_for_attribute(attribute)
33
+
34
+ rows.each do |row|
35
+ plaintext = row[attribute.to_s]
36
+ next if plaintext.nil? || encrypted?(plaintext)
37
+
38
+ encrypted = encryptor.serialize(plaintext)
39
+ connection.execute(
40
+ "UPDATE #{table_name} SET #{attribute} = #{connection.quote(encrypted)} WHERE id = #{connection.quote(row['id'])}"
41
+ )
42
+ end
43
+ end
44
+
45
+ # Active Record Encryption stores a JSON envelope containing a "p" (payload)
46
+ # key; treat anything already in that shape as encrypted and leave it alone.
47
+ def encrypted?(value)
48
+ return false unless value.is_a?(String)
49
+
50
+ value.start_with?("{") && value.include?('"p":')
51
+ rescue StandardError
52
+ false
53
+ end
54
+ end
data/db/seeds.rb CHANGED
@@ -122,7 +122,7 @@ module CollavreGithub
122
122
  end
123
123
 
124
124
  def default_llm_model
125
- ENV.fetch("COLLAVRE_DEFAULT_LLM_MODEL", "gemini-3-flash-preview")
125
+ ENV.fetch("COLLAVRE_DEFAULT_LLM_MODEL", "gemini-3.1-flash-lite")
126
126
  end
127
127
  end
128
128
  end
@@ -1,3 +1,3 @@
1
1
  module CollavreGithub
2
- VERSION = "0.6.0"
2
+ VERSION = "0.6.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre_github
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -66,7 +66,9 @@ files:
66
66
  - app/controllers/collavre_github/auth_controller.rb
67
67
  - app/controllers/collavre_github/creatives/integrations_controller.rb
68
68
  - app/controllers/collavre_github/webhooks_controller.rb
69
+ - app/javascript/__tests__/github_wizard_state.test.js
69
70
  - app/javascript/collavre_github.js
71
+ - app/javascript/github_wizard_state.js
70
72
  - app/jobs/collavre_github/initial_markdown_sync_job.rb
71
73
  - app/jobs/collavre_github/markdown_sync_job.rb
72
74
  - app/models/collavre_github/account.rb
@@ -88,6 +90,7 @@ files:
88
90
  - app/views/collavre_github/auth/setup.html.erb
89
91
  - app/views/collavre_github/integrations/_modal.html.erb
90
92
  - config/initializers/integration_settings.rb
93
+ - config/initializers/read_only_source.rb
91
94
  - config/locales/en.yml
92
95
  - config/locales/ko.yml
93
96
  - config/routes.rb
@@ -97,6 +100,7 @@ files:
97
100
  - db/migrate/20260219095224_remove_github_gemini_prompt_from_creatives.rb
98
101
  - db/migrate/20260412000000_add_markdown_sync_to_repository_links.rb
99
102
  - db/migrate/20260416000000_add_markdown_root_creative_index.rb
103
+ - db/migrate/20260711000000_encrypt_github_repository_link_webhook_secrets.rb
100
104
  - db/seeds.rb
101
105
  - lib/collavre_github.rb
102
106
  - lib/collavre_github/engine.rb