collavre_github 0.6.3 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/collavre_github/creatives/integrations_controller.rb +253 -36
- data/app/controllers/collavre_github/webhooks_controller.rb +562 -37
- data/app/jobs/collavre_github/webhook_delivery_prune_job.rb +14 -0
- data/app/models/collavre_github/github_pr_channel.rb +72 -7
- data/app/models/collavre_github/repository_link.rb +61 -0
- data/app/models/collavre_github/webhook_delivery.rb +159 -0
- data/app/services/collavre_github/client.rb +14 -0
- data/app/services/collavre_github/pr_channel_state_updater.rb +83 -0
- data/app/services/collavre_github/repository_identity_synchronizer.rb +145 -0
- data/app/services/collavre_github/repository_provisioning_lock.rb +95 -0
- data/app/services/collavre_github/tools/concerns/pr_channel_locator.rb +50 -0
- data/app/services/collavre_github/tools/pr_monitor_service.rb +80 -61
- data/app/services/collavre_github/tools/pr_state_set_service.rb +170 -0
- data/app/services/collavre_github/webhook_provisioner.rb +600 -41
- data/app/services/collavre_github/webhook_reprovisioner.rb +118 -0
- data/config/locales/en.yml +1 -0
- data/config/locales/ko.yml +1 -0
- data/config/routes.rb +13 -3
- data/db/migrate/20260727000001_add_webhook_hook_id_to_github_repository_links.rb +17 -0
- data/db/migrate/20260727000002_create_github_webhook_deliveries.rb +30 -0
- data/db/migrate/20260729000000_add_repository_id_index_to_github_repository_links.rb +13 -0
- data/db/seeds.rb +1 -0
- data/lib/collavre_github/version.rb +1 -1
- metadata +12 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60bb9a302002d78180f63d50dd43ac48b38b7213eb43d0452691c8ab316b3052
|
|
4
|
+
data.tar.gz: ac9ccd2a8acec5d091ac77690dfe5c40a26173dc1cb9e94463c943f1757dc969
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bf6242f4f70a28ac275ded93dbfc21286609a1ea7f59460f6c561dacf6bcabe779b7848488f7fed58ac147500ebc821c2a3bbfce755f7eaf6c2cd5f073ebfe09
|
|
7
|
+
data.tar.gz: 5a1ac169a57d1dbd4249a32d8beeb0daf5cd3efa62e461a1c6fa8f1cd6aa61c4956db7a8b8c7089bc4d4167d85ea5e4d4397e1e3afc90af2b99a37e1082824fa
|
|
@@ -45,48 +45,258 @@ module CollavreGithub
|
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
integration_attributes = integration_params
|
|
48
|
+
markdown_sync = integration_attributes[:markdown_sync]
|
|
49
|
+
markdown_sync_by_name =
|
|
50
|
+
if markdown_sync.is_a?(ActionController::Parameters) || markdown_sync.is_a?(Hash)
|
|
51
|
+
markdown_sync.to_h.each_with_object({}) do |(full_name, enabled), toggles|
|
|
52
|
+
key = full_name.to_s.downcase
|
|
53
|
+
toggles[key] ||= []
|
|
54
|
+
toggles[key] << ActiveModel::Type::Boolean.new.cast(enabled)
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
{}
|
|
58
|
+
end
|
|
48
59
|
repositories = Array(integration_attributes[:repositories]).map(&:to_s).uniq
|
|
60
|
+
existing_links = linked_repository_links(account).to_a
|
|
61
|
+
existing_by_name = existing_links.index_by { |link| link.repository_full_name.downcase }
|
|
62
|
+
client = CollavreGithub::Client.new(account)
|
|
63
|
+
identities = {}
|
|
64
|
+
repositories.each do |full_name|
|
|
65
|
+
normalized_name = full_name.downcase
|
|
66
|
+
existing = existing_by_name[normalized_name]
|
|
67
|
+
next if existing && existing.repository_id.blank?
|
|
68
|
+
|
|
69
|
+
identity = client.repository_identity(full_name)
|
|
70
|
+
unless identity&.id.present? && identity&.full_name.present?
|
|
71
|
+
render json: { error: I18n.t("collavre_github.setup.save_error") },
|
|
72
|
+
status: :unprocessable_entity
|
|
73
|
+
return
|
|
74
|
+
end
|
|
49
75
|
|
|
50
|
-
|
|
76
|
+
if existing && existing.repository_id.to_s != identity.id.to_s
|
|
77
|
+
Rails.logger.warn(
|
|
78
|
+
"[CollavreGithub] rejecting integration save for stale link #{existing.id}: " \
|
|
79
|
+
"#{full_name} resolves to repository #{identity.id}, not #{existing.repository_id}"
|
|
80
|
+
)
|
|
81
|
+
render json: { error: I18n.t("collavre_github.setup.save_error") },
|
|
82
|
+
status: :unprocessable_entity
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
identities[normalized_name] = identity
|
|
87
|
+
identities[identity.full_name.downcase] = identity
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
markdown_sync_by_repository = {}
|
|
91
|
+
repository_groups = repositories.group_by do |full_name|
|
|
92
|
+
normalized_name = full_name.downcase
|
|
93
|
+
existing = existing_by_name[normalized_name]
|
|
94
|
+
identity = identities[normalized_name]
|
|
95
|
+
repository_id = identity&.id || existing&.repository_id
|
|
96
|
+
|
|
97
|
+
if repository_id.present?
|
|
98
|
+
[ :id, repository_id.to_s ]
|
|
99
|
+
else
|
|
100
|
+
canonical_name = identity&.full_name || existing&.repository_full_name || full_name
|
|
101
|
+
[ :name, canonical_name.downcase ]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
repositories = repository_groups.values.map do |aliases|
|
|
105
|
+
identity = aliases.filter_map { |full_name| identities[full_name.downcase] }.first
|
|
106
|
+
existing = aliases.filter_map { |full_name| existing_by_name[full_name.downcase] }.first
|
|
107
|
+
canonical_name = identity&.full_name || existing&.repository_full_name || aliases.first
|
|
108
|
+
toggle_keys = (aliases.map(&:downcase) + [ canonical_name.downcase ]).uniq
|
|
109
|
+
toggle_values = toggle_keys.each_with_object([]) do |key, values|
|
|
110
|
+
values.concat(markdown_sync_by_name.fetch(key, []))
|
|
111
|
+
end.uniq
|
|
112
|
+
if toggle_values.length > 1
|
|
113
|
+
render json: { error: I18n.t("collavre_github.setup.save_error") },
|
|
114
|
+
status: :unprocessable_entity
|
|
115
|
+
return
|
|
116
|
+
end
|
|
117
|
+
if toggle_values.length == 1
|
|
118
|
+
markdown_sync_by_repository[canonical_name.downcase] = toggle_values.first
|
|
119
|
+
end
|
|
120
|
+
canonical_name
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Reject deterministic canonical-name collisions before any hook is
|
|
124
|
+
# touched. A NULL or different-id row is not the verified repository
|
|
125
|
+
# and must never replace its old-name exact-id anchor.
|
|
126
|
+
repositories.each do |full_name|
|
|
127
|
+
identity = identities[full_name.downcase]
|
|
128
|
+
next unless identity
|
|
129
|
+
|
|
130
|
+
canonical_conflict = CollavreGithub::RepositoryLink.identity_conflict_in_link_scope(
|
|
131
|
+
creative: @origin,
|
|
132
|
+
full_name: identity.full_name,
|
|
133
|
+
repository_id: identity.id
|
|
134
|
+
)
|
|
135
|
+
next unless canonical_conflict
|
|
136
|
+
|
|
137
|
+
render json: { error: I18n.t("collavre_github.setup.save_error") },
|
|
138
|
+
status: :unprocessable_entity
|
|
139
|
+
return
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# A legacy NULL-id row may point at a repository name GitHub has since
|
|
143
|
+
# reassigned. Enabling import would read that unrelated repository and
|
|
144
|
+
# archive the creative's existing sync tree before identity is proven.
|
|
145
|
+
if markdown_sync_by_repository.present?
|
|
146
|
+
unsafe_legacy_enable = repositories.any? do |full_name|
|
|
147
|
+
link = existing_by_name[full_name.downcase]
|
|
148
|
+
next false unless link && link.repository_id.blank?
|
|
149
|
+
next false unless markdown_sync_by_repository.key?(full_name.downcase)
|
|
150
|
+
|
|
151
|
+
markdown_sync_by_repository[full_name.downcase]
|
|
152
|
+
end
|
|
153
|
+
if unsafe_legacy_enable
|
|
154
|
+
render json: { error: I18n.t("collavre_github.setup.save_error") },
|
|
155
|
+
status: :unprocessable_entity
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Provision each verified repository in its own transaction. If a
|
|
161
|
+
# later repository fails, earlier successful GitHub mutations retain
|
|
162
|
+
# their matching local link/secret instead of being rolled back into a
|
|
163
|
+
# ghost-hook state.
|
|
164
|
+
live_repository_names = {}
|
|
165
|
+
repositories.each do |full_name|
|
|
166
|
+
identity = identities[full_name.downcase]
|
|
167
|
+
next unless identity
|
|
168
|
+
|
|
169
|
+
toggle_key = identity.full_name.downcase
|
|
170
|
+
toggle_present = markdown_sync_by_repository.key?(toggle_key)
|
|
171
|
+
toggle_enabled = markdown_sync_by_repository[toggle_key]
|
|
172
|
+
repository_failed = false
|
|
173
|
+
sync_job_link_id = nil
|
|
174
|
+
CollavreGithub::RepositoryProvisioningLock.with_lock(identity.id) do
|
|
175
|
+
CollavreGithub::RepositoryProvisioningLock.with_repository_name_lock(identity.full_name) do
|
|
176
|
+
CollavreGithub::RepositoryLink.transaction(requires_new: true) do
|
|
177
|
+
live_identity = client.repository_identity(full_name)
|
|
178
|
+
unless live_identity&.id.to_s == identity.id.to_s &&
|
|
179
|
+
live_identity&.full_name.present? &&
|
|
180
|
+
live_identity.full_name.to_s.casecmp?(identity.full_name.to_s)
|
|
181
|
+
repository_failed = true
|
|
182
|
+
raise ActiveRecord::Rollback
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
canonical_conflict = CollavreGithub::RepositoryLink.identity_conflict_in_link_scope(
|
|
186
|
+
creative: @origin,
|
|
187
|
+
full_name: live_identity.full_name,
|
|
188
|
+
repository_id: live_identity.id
|
|
189
|
+
)
|
|
190
|
+
if canonical_conflict
|
|
191
|
+
repository_failed = true
|
|
192
|
+
raise ActiveRecord::Rollback
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
anchor = linked_repository_links(account)
|
|
196
|
+
.where(repository_id: live_identity.id)
|
|
197
|
+
.order(:id)
|
|
198
|
+
.first
|
|
199
|
+
link = if anchor
|
|
200
|
+
synchronized = CollavreGithub::RepositoryIdentitySynchronizer.call(
|
|
201
|
+
anchor: anchor,
|
|
202
|
+
repository_id: live_identity.id,
|
|
203
|
+
full_name: live_identity.full_name,
|
|
204
|
+
trusted_secret: anchor.webhook_secret
|
|
205
|
+
)
|
|
206
|
+
synchronized.find do |candidate|
|
|
207
|
+
candidate.creative_id == @origin.id &&
|
|
208
|
+
candidate.repository_id.to_s == live_identity.id.to_s
|
|
209
|
+
end
|
|
210
|
+
else
|
|
211
|
+
@origin.github_repository_links
|
|
212
|
+
.where("LOWER(repository_full_name) = ?", live_identity.full_name.downcase)
|
|
213
|
+
.where(repository_id: live_identity.id)
|
|
214
|
+
.first
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
unless link
|
|
218
|
+
link = @origin.github_repository_links.create!(
|
|
219
|
+
github_account: account,
|
|
220
|
+
repository_full_name: live_identity.full_name,
|
|
221
|
+
repository_id: live_identity.id
|
|
222
|
+
)
|
|
223
|
+
end
|
|
224
|
+
link.update!(github_account: account) if link.github_account_id != account.id
|
|
225
|
+
|
|
226
|
+
if toggle_present
|
|
227
|
+
enabled = toggle_enabled
|
|
228
|
+
was_enabled = link.markdown_sync_enabled?
|
|
229
|
+
link.update!(markdown_sync_enabled: enabled)
|
|
230
|
+
sync_job_link_id = link.id if enabled && !was_enabled
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
results = CollavreGithub::WebhookProvisioner.ensure_for_links(
|
|
234
|
+
account: account,
|
|
235
|
+
links: [ link ],
|
|
236
|
+
webhook_url: github_webhook_url,
|
|
237
|
+
force_hook_refresh: true
|
|
238
|
+
)
|
|
239
|
+
if results.any? { |_candidate, status| status == :failed }
|
|
240
|
+
repository_failed = true
|
|
241
|
+
raise ActiveRecord::Rollback
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
live_repository_names[identity.id.to_s] = live_identity.full_name
|
|
245
|
+
if toggle_present
|
|
246
|
+
markdown_sync_by_repository[live_identity.full_name.downcase] = toggle_enabled
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
if repository_failed
|
|
253
|
+
render json: { error: I18n.t("collavre_github.setup.save_error") },
|
|
254
|
+
status: :unprocessable_entity
|
|
255
|
+
return
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
if sync_job_link_id
|
|
259
|
+
CollavreGithub::InitialMarkdownSyncJob.perform_later(sync_job_link_id)
|
|
260
|
+
end
|
|
261
|
+
end
|
|
51
262
|
|
|
263
|
+
repositories.map! do |full_name|
|
|
264
|
+
identity = identities[full_name.downcase]
|
|
265
|
+
identity ? live_repository_names.fetch(identity.id.to_s, full_name) : full_name
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
links = nil
|
|
269
|
+
sync_job_link_ids = []
|
|
52
270
|
CollavreGithub::RepositoryLink.transaction do
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
)
|
|
271
|
+
selected_names = repositories.map(&:downcase)
|
|
272
|
+
current_links = linked_repository_links(account)
|
|
273
|
+
if selected_names.empty?
|
|
274
|
+
current_links.delete_all
|
|
275
|
+
else
|
|
276
|
+
current_links
|
|
277
|
+
.where.not("LOWER(repository_full_name) IN (?)", selected_names)
|
|
278
|
+
.delete_all
|
|
62
279
|
end
|
|
63
280
|
|
|
64
281
|
links = linked_repository_links(account).to_a
|
|
65
|
-
end
|
|
66
282
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
next unless markdown_sync.key?(repo)
|
|
283
|
+
# Enqueue the initial import only after selection and every hook
|
|
284
|
+
# provision have succeeded.
|
|
285
|
+
if markdown_sync_by_repository.present?
|
|
286
|
+
links.each do |link|
|
|
287
|
+
repo = link.repository_full_name
|
|
288
|
+
next unless markdown_sync_by_repository.key?(repo.downcase)
|
|
74
289
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
290
|
+
enabled = markdown_sync_by_repository[repo.downcase]
|
|
291
|
+
was_enabled = link.markdown_sync_enabled?
|
|
292
|
+
link.update!(markdown_sync_enabled: enabled)
|
|
78
293
|
|
|
79
|
-
|
|
80
|
-
CollavreGithub::InitialMarkdownSyncJob.perform_later(link.id)
|
|
294
|
+
sync_job_link_ids << link.id if enabled && !was_enabled
|
|
81
295
|
end
|
|
82
296
|
end
|
|
83
297
|
end
|
|
84
298
|
|
|
85
|
-
CollavreGithub::
|
|
86
|
-
account: account,
|
|
87
|
-
links: links,
|
|
88
|
-
webhook_url: github_webhook_url
|
|
89
|
-
) if links.present?
|
|
299
|
+
sync_job_link_ids.each { |link_id| CollavreGithub::InitialMarkdownSyncJob.perform_later(link_id) }
|
|
90
300
|
|
|
91
301
|
render json: {
|
|
92
302
|
success: true,
|
|
@@ -102,6 +312,13 @@ module CollavreGithub
|
|
|
102
312
|
}
|
|
103
313
|
rescue ActiveRecord::RecordInvalid => e
|
|
104
314
|
render json: { error: e.message }, status: :unprocessable_entity
|
|
315
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
316
|
+
Rails.logger.warn(
|
|
317
|
+
"[CollavreGithub] repository identity lookup failed while saving integration: " \
|
|
318
|
+
"#{e.class}: #{e.message}"
|
|
319
|
+
)
|
|
320
|
+
render json: { error: I18n.t("collavre_github.setup.save_error") },
|
|
321
|
+
status: :unprocessable_entity
|
|
105
322
|
end
|
|
106
323
|
|
|
107
324
|
def resync
|
|
@@ -145,7 +362,7 @@ module CollavreGithub
|
|
|
145
362
|
|
|
146
363
|
scope = linked_repository_links(account)
|
|
147
364
|
|
|
148
|
-
|
|
365
|
+
removed_links = []
|
|
149
366
|
|
|
150
367
|
if repositories.present?
|
|
151
368
|
links_to_remove = scope.where(repository_full_name: repositories).to_a
|
|
@@ -156,7 +373,7 @@ module CollavreGithub
|
|
|
156
373
|
end
|
|
157
374
|
|
|
158
375
|
CollavreGithub::RepositoryLink.transaction do
|
|
159
|
-
|
|
376
|
+
removed_links = links_to_remove
|
|
160
377
|
links_to_remove.each(&:destroy!)
|
|
161
378
|
end
|
|
162
379
|
elsif repository
|
|
@@ -167,20 +384,20 @@ module CollavreGithub
|
|
|
167
384
|
end
|
|
168
385
|
|
|
169
386
|
CollavreGithub::RepositoryLink.transaction do
|
|
170
|
-
|
|
387
|
+
removed_links = [ link ]
|
|
171
388
|
link.destroy!
|
|
172
389
|
end
|
|
173
390
|
else
|
|
174
391
|
CollavreGithub::RepositoryLink.transaction do
|
|
175
|
-
|
|
176
|
-
|
|
392
|
+
removed_links = scope.to_a
|
|
393
|
+
removed_links.each(&:destroy!)
|
|
177
394
|
end
|
|
178
395
|
end
|
|
179
396
|
|
|
180
|
-
if
|
|
181
|
-
CollavreGithub::WebhookProvisioner.
|
|
397
|
+
if removed_links.present?
|
|
398
|
+
CollavreGithub::WebhookProvisioner.remove_for_links(
|
|
182
399
|
account: account,
|
|
183
|
-
|
|
400
|
+
links: removed_links,
|
|
184
401
|
webhook_url: github_webhook_url
|
|
185
402
|
)
|
|
186
403
|
end
|