collavre_github 0.6.1 → 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 +4 -4
- data/app/javascript/__tests__/github_wizard_state.test.js +57 -0
- data/app/javascript/collavre_github.js +17 -8
- data/app/javascript/github_wizard_state.js +40 -0
- data/app/models/collavre_github/repository_link.rb +8 -0
- data/app/services/collavre_github/markdown_sync/incremental_sync_service.rb +14 -11
- data/app/services/collavre_github/markdown_sync/initial_import_service.rb +3 -3
- data/config/initializers/read_only_source.rb +13 -0
- data/db/migrate/20260711000000_encrypt_github_repository_link_webhook_secrets.rb +54 -0
- data/lib/collavre_github/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c290ca646f38dca20fc5579da040eb6afe437b763298b130b4cfdf33528b53b
|
|
4
|
+
data.tar.gz: 42b03d3de6dc5b1e7c3150b0acbf42400d1057f0c3dc0281ede2e91f13e9e70e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import { csrfToken, showError, clearError, updateStepVisibility, openOAuthPopup, fetchWithCsrf, setupModalClose } from 'collavre/modules/integration_wizard';
|
|
2
2
|
import { alertDialog, confirmDialog } from 'collavre/lib/utils/dialog';
|
|
3
|
+
import { deriveConnectState, shouldShowConnectNext } from './github_wizard_state.js';
|
|
3
4
|
|
|
4
5
|
let githubIntegrationInitialized = false;
|
|
5
6
|
|
|
@@ -51,6 +52,10 @@ if (!githubIntegrationInitialized) {
|
|
|
51
52
|
let webhookDetails = {};
|
|
52
53
|
|
|
53
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;
|
|
54
59
|
let selectedReposForDeletion = new Set();
|
|
55
60
|
|
|
56
61
|
const markdownSyncList = document.getElementById('github-markdown-sync-list');
|
|
@@ -64,6 +69,7 @@ if (!githubIntegrationInitialized) {
|
|
|
64
69
|
webhookDetails = {};
|
|
65
70
|
|
|
66
71
|
hasExistingIntegration = false;
|
|
72
|
+
userConnected = false;
|
|
67
73
|
selectedReposForDeletion = new Set();
|
|
68
74
|
statusEl.textContent = '';
|
|
69
75
|
errorEl.style.display = 'none';
|
|
@@ -101,7 +107,10 @@ if (!githubIntegrationInitialized) {
|
|
|
101
107
|
|
|
102
108
|
if (currentStep === 'connect') {
|
|
103
109
|
prevBtn.style.display = 'none';
|
|
104
|
-
|
|
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 })) {
|
|
105
114
|
nextBtn.style.display = 'block';
|
|
106
115
|
nextBtn.disabled = false;
|
|
107
116
|
} else {
|
|
@@ -198,18 +207,19 @@ if (!githubIntegrationInitialized) {
|
|
|
198
207
|
fetch(`/github/creatives/${creativeId}/integration`, { headers: { Accept: 'application/json' } })
|
|
199
208
|
.then(function (response) { return response.json(); })
|
|
200
209
|
.then(function (data) {
|
|
210
|
+
var connectState = deriveConnectState(data);
|
|
211
|
+
userConnected = connectState.userConnected;
|
|
212
|
+
hasExistingIntegration = connectState.hasExistingIntegration;
|
|
213
|
+
|
|
201
214
|
if (!data.connected) {
|
|
202
|
-
|
|
203
|
-
if (allRepos.length > 0) {
|
|
215
|
+
if (hasExistingIntegration) {
|
|
204
216
|
// Other users have linked repositories to this creative
|
|
205
217
|
statusEl.textContent = existingMessage;
|
|
206
|
-
|
|
207
|
-
renderExistingConnections(allRepos, true);
|
|
218
|
+
renderExistingConnections(data.all_repositories || [], true);
|
|
208
219
|
if (connectMessage) connectMessage.style.display = 'none';
|
|
209
220
|
if (loginBtn) loginBtn.style.display = 'inline-block';
|
|
210
221
|
} else {
|
|
211
222
|
statusEl.textContent = '';
|
|
212
|
-
hasExistingIntegration = false;
|
|
213
223
|
renderExistingConnections([]);
|
|
214
224
|
if (connectMessage) connectMessage.style.display = '';
|
|
215
225
|
if (loginBtn) loginBtn.style.display = 'inline-block';
|
|
@@ -230,8 +240,7 @@ if (!githubIntegrationInitialized) {
|
|
|
230
240
|
}
|
|
231
241
|
});
|
|
232
242
|
|
|
233
|
-
hasExistingIntegration
|
|
234
|
-
|
|
243
|
+
// hasExistingIntegration was derived above from selected_repositories.
|
|
235
244
|
if (loginBtn) loginBtn.style.display = 'none';
|
|
236
245
|
renderExistingConnections(Array.from(selectedRepos));
|
|
237
246
|
|
|
@@ -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.
|
|
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.
|
|
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
|
|
136
|
-
|
|
137
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
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.
|
|
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
|