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.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/collavre_github/creatives/integrations_controller.rb +253 -36
  3. data/app/controllers/collavre_github/webhooks_controller.rb +562 -37
  4. data/app/jobs/collavre_github/webhook_delivery_prune_job.rb +14 -0
  5. data/app/models/collavre_github/github_pr_channel.rb +72 -7
  6. data/app/models/collavre_github/repository_link.rb +61 -0
  7. data/app/models/collavre_github/webhook_delivery.rb +159 -0
  8. data/app/services/collavre_github/client.rb +14 -0
  9. data/app/services/collavre_github/pr_channel_state_updater.rb +83 -0
  10. data/app/services/collavre_github/repository_identity_synchronizer.rb +145 -0
  11. data/app/services/collavre_github/repository_provisioning_lock.rb +95 -0
  12. data/app/services/collavre_github/tools/concerns/pr_channel_locator.rb +50 -0
  13. data/app/services/collavre_github/tools/pr_monitor_service.rb +80 -61
  14. data/app/services/collavre_github/tools/pr_state_set_service.rb +170 -0
  15. data/app/services/collavre_github/webhook_provisioner.rb +600 -41
  16. data/app/services/collavre_github/webhook_reprovisioner.rb +118 -0
  17. data/config/locales/en.yml +1 -0
  18. data/config/locales/ko.yml +1 -0
  19. data/config/routes.rb +13 -3
  20. data/db/migrate/20260727000001_add_webhook_hook_id_to_github_repository_links.rb +17 -0
  21. data/db/migrate/20260727000002_create_github_webhook_deliveries.rb +30 -0
  22. data/db/migrate/20260729000000_add_repository_id_index_to_github_repository_links.rb +13 -0
  23. data/db/seeds.rb +1 -0
  24. data/lib/collavre_github/version.rb +1 -1
  25. metadata +12 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9dbd03c855d55f188d6e25e4317c07e9ef761edd7ef49b0c66d91fb0a4850671
4
- data.tar.gz: 5adfcd29c881260dadc697804164b8053b86652f0bdd48e2937e58f9fc91ea0b
3
+ metadata.gz: 60bb9a302002d78180f63d50dd43ac48b38b7213eb43d0452691c8ab316b3052
4
+ data.tar.gz: ac9ccd2a8acec5d091ac77690dfe5c40a26173dc1cb9e94463c943f1757dc969
5
5
  SHA512:
6
- metadata.gz: 4806026dda6dfa362b2eb8d2eccc338510c2fbb11449683c8f636dcbb7a85e598667e8e06b828f4183b465118b99da8ab91ee0b98bd298770cb214c8a1cd52f2
7
- data.tar.gz: 9bf9c6ff9170dc70ffb62403b0557df7c2a0f34bd00683edd8a30daee9da0af1fba137470bef3139a94d2fe370a6896b055c30d518d766303466a25d5e5cde9c
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
- links = nil
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
- linked_repository_links(account)
54
- .where.not(repository_full_name: repositories)
55
- .delete_all
56
-
57
- repositories.each do |full_name|
58
- @origin.github_repository_links.find_or_create_by!(
59
- github_account: account,
60
- repository_full_name: full_name
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
- # Handle markdown sync toggle BEFORE webhook provisioning
68
- # so events_for() sees the updated markdown_sync_enabled state
69
- markdown_sync = integration_attributes[:markdown_sync]
70
- if markdown_sync.is_a?(ActionController::Parameters) || markdown_sync.is_a?(Hash)
71
- links.each do |link|
72
- repo = link.repository_full_name
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
- enabled = ActiveModel::Type::Boolean.new.cast(markdown_sync[repo])
76
- was_enabled = link.markdown_sync_enabled?
77
- link.update!(markdown_sync_enabled: enabled)
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
- if enabled && !was_enabled
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::WebhookProvisioner.ensure_for_links(
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
- removed_repositories = []
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
- removed_repositories = links_to_remove.map(&:repository_full_name)
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
- removed_repositories = [ link.repository_full_name ]
387
+ removed_links = [ link ]
171
388
  link.destroy!
172
389
  end
173
390
  else
174
391
  CollavreGithub::RepositoryLink.transaction do
175
- removed_repositories = scope.pluck(:repository_full_name)
176
- scope.destroy_all
392
+ removed_links = scope.to_a
393
+ removed_links.each(&:destroy!)
177
394
  end
178
395
  end
179
396
 
180
- if removed_repositories.present?
181
- CollavreGithub::WebhookProvisioner.remove_for_repositories(
397
+ if removed_links.present?
398
+ CollavreGithub::WebhookProvisioner.remove_for_links(
182
399
  account: account,
183
- repositories: removed_repositories,
400
+ links: removed_links,
184
401
  webhook_url: github_webhook_url
185
402
  )
186
403
  end