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
@@ -6,62 +6,164 @@ module CollavreGithub
6
6
  # a channel that silently misses comments. `pull_request` is required by
7
7
  # the auto-attach + close detection paths.
8
8
  CHANNEL_EVENTS = %w[issue_comment pull_request_review pull_request_review_comment].freeze
9
- EVENTS = (%w[pull_request] + CHANNEL_EVENTS).freeze
10
- EVENTS_WITH_PUSH = (%w[pull_request push] + CHANNEL_EVENTS).freeze
9
+
10
+ # `repository` carries the `renamed` action. Without it a rename is
11
+ # invisible to this app: `repository_full_name` goes stale on every link at
12
+ # once and, for links with no `repository_id` yet, there is no other key to
13
+ # find them by — so every subsequent delivery is answered 401 and the
14
+ # rename event that would repair it is rejected along with the rest.
15
+ # NOT a member of CHANNEL_EVENTS: it is routing maintenance, and the
16
+ # controller drops it rather than formatting it into any feed.
17
+ MAINTENANCE_EVENTS = %w[repository].freeze
18
+ EVENTS = (%w[pull_request] + CHANNEL_EVENTS + MAINTENANCE_EVENTS).freeze
19
+ EVENTS_WITH_PUSH = (%w[pull_request push] + CHANNEL_EVENTS + MAINTENANCE_EVENTS).freeze
11
20
  CONTENT_TYPE = "json".freeze
12
21
 
13
- def self.ensure_for_links(account:, links:, webhook_url:)
14
- new(account: account, webhook_url: webhook_url).ensure_for_links(Array(links))
22
+ # Last path segment of the deprecated singular `post "webhook"` route. The
23
+ # route still answers so that untouched repositories keep working, but a
24
+ # hook pointing at it is redundant with the plural one, so provisioning
25
+ # migrates the repository by deleting it. Derived from `webhook_url` at
26
+ # runtime so the engine's mount point stays the single source of truth.
27
+ LEGACY_ROUTE_SEGMENT = "webhook".freeze
28
+ ROUTE_SEGMENT = "webhooks".freeze
29
+
30
+ def self.ensure_for_links(account:, links:, webhook_url:, force_hook_refresh: false)
31
+ new(
32
+ account: account,
33
+ webhook_url: webhook_url,
34
+ force_hook_refresh: force_hook_refresh
35
+ ).ensure_for_links(Array(links))
15
36
  end
16
37
 
17
- def self.remove_for_repositories(account:, repositories:, webhook_url:)
18
- new(account: account, webhook_url: webhook_url).remove_for_repositories(Array(repositories))
38
+ def self.remove_for_links(account:, links:, webhook_url:)
39
+ new(account: account, webhook_url: webhook_url).remove_for_links(Array(links))
19
40
  end
20
41
 
21
- def initialize(account:, webhook_url:, client: CollavreGithub::Client.new(account))
42
+ def initialize(
43
+ account:,
44
+ webhook_url:,
45
+ client: CollavreGithub::Client.new(account),
46
+ force_hook_refresh: false
47
+ )
22
48
  @client = client
23
49
  @webhook_url = webhook_url
50
+ @force_hook_refresh = force_hook_refresh
24
51
  end
25
52
 
26
53
  # Returns [[link, status], ...] so callers can detect silent GitHub
27
54
  # rejections. status is one of:
28
55
  # :created - new hook created
29
56
  # :updated - existing hook patched (events/url/secret)
30
- # :secret_aligned - non-primary link with existing hook; only the local
31
- # RepositoryLink secret was aligned. No GitHub call.
57
+ # :secret_aligned - non-primary link with existing hook; the local
58
+ # RepositoryLink secret was aligned and the hook itself
59
+ # was neither created nor patched. NOT a local-only
60
+ # operation: registration and legacy-hook cleanup still
61
+ # run, so GitHub may be called and a hook deleted. In
62
+ # particular the hook's event list is left untouched —
63
+ # see `provision_hook`.
64
+ # :shared - a hook registered in THIS database already exists, so
65
+ # another instance of this app owns it. No hook of this
66
+ # instance's own remains: a second one would make GitHub
67
+ # deliver every event twice. Its events and secret are
68
+ # refreshed, but its URL is left alone — rewriting that
69
+ # would break the sibling and start a rewrite war
70
+ # between the two. Also reported when this run created a
71
+ # hook, lost the registration race to a sibling doing
72
+ # the same concurrently, and deleted the hook it made.
32
73
  # :failed - Octokit/Faraday error OR Client returned nil
33
74
  def ensure_for_links(links)
34
75
  links.map { |link| [ link, ensure_webhook(link) ] }
35
76
  end
36
77
 
37
- def remove_for_repositories(repositories)
38
- # Batch-query to avoid N+1: find all repo names that still have links
39
- linked_names = CollavreGithub::RepositoryLink
40
- .where(repository_full_name: repositories)
41
- .distinct
42
- .pluck(:repository_full_name)
43
- .to_set
44
-
45
- repositories.each do |repository_full_name|
46
- next if linked_names.include?(repository_full_name)
78
+ # Link objects retain their stable identity after destroy, allowing removal
79
+ # to find a surviving sibling even when it still stores a pre-rename name.
80
+ # Once the last ID-backed link is gone, re-resolve its stored name and
81
+ # require GitHub's live numeric id to match before touching the remote hook.
82
+ # NULL-id links fail closed: their name may have been reassigned and cannot
83
+ # safely identify a repository for an outbound mutation.
84
+ def remove_for_links(links)
85
+ links
86
+ .select { |link| link.repository_id.present? }
87
+ .group_by { |link| link.repository_id.to_s }
88
+ .each_value do |candidates|
89
+ repository_id = candidates.first.repository_id
90
+ CollavreGithub::RepositoryProvisioningLock.with_lock(repository_id) do
91
+ next if CollavreGithub::RepositoryLink.where(repository_id: repository_id).exists?
47
92
 
48
- remove_webhook(repository_full_name)
49
- end
93
+ verified_name = candidates.filter_map { |link| verified_removal_name(link) }.first
94
+ remove_webhook(verified_name) if verified_name
95
+ end
96
+ end
50
97
  end
51
98
 
52
99
  private
53
100
 
54
- attr_reader :client, :webhook_url
101
+ attr_reader :client, :webhook_url, :provisioning_link, :force_hook_refresh,
102
+ :superseding_hook
55
103
 
56
104
  def ensure_webhook(link)
105
+ @provisioning_link = link
106
+ @superseding_hook = nil
57
107
  repository_full_name = link.repository_full_name
58
108
  primary_link = primary_link_for(repository_full_name)
59
- hook = find_existing_hook(repository_full_name)
109
+ hooks = repository_hooks(repository_full_name)
110
+ # Read before provisioning, because registering the replacement overwrites
111
+ # it — and for a legacy hook under another host it is the only evidence
112
+ # that the hook belongs to a deployment sharing this database, which is
113
+ # what makes it ours to migrate.
114
+ prior_registration = registered_hook_id(repository_full_name)
115
+ hook = find_own_hook(hooks)
116
+ shared = find_shared_hook(hooks, repository_full_name, hook)
117
+
118
+ if shared && hook
119
+ # A leftover from before hooks were registered: this instance owns one
120
+ # and a sibling owns another, both feeding this database. Inbound
121
+ # deliveries are already deduplicated by GUID, so the extra hook only
122
+ # wastes bandwidth — not enough to justify deleting a live hook out
123
+ # from under whoever created it, but worth surfacing.
124
+ Rails.logger.warn(
125
+ "[CollavreGithub] #{repository_full_name} carries both this instance's hook " \
126
+ "#{hook_url(hook)} and registered hook #{hook_url(shared)}; delete one to stop " \
127
+ "GitHub sending every delivery twice"
128
+ )
129
+ end
130
+
131
+ status = provision_hook(link, repository_full_name, primary_link, hooks, hook, shared)
132
+
133
+ # Deliberately AFTER a replacement is in place. Deleting first left the
134
+ # repository with no hook at all whenever the create that followed failed
135
+ # — a transient GitHub error was enough — and callers report success
136
+ # regardless, so events stopped until provisioning happened to run again.
137
+ # Keeping the legacy hook on failure costs at most a duplicate delivery,
138
+ # which the GUID ledger already collapses.
139
+ delete_legacy_hooks(repository_full_name, hooks, prior_registration) unless status == :failed
60
140
 
141
+ status
142
+ rescue Octokit::Error => e
143
+ Rails.logger.warn(
144
+ "GitHub webhook provisioning failed for #{repository_full_name}: #{e.message}"
145
+ )
146
+ :failed
147
+ ensure
148
+ @provisioning_link = nil
149
+ @superseding_hook = nil
150
+ end
151
+
152
+ def provision_hook(link, repository_full_name, primary_link, hooks, hook, shared)
61
153
  if hook
154
+ register_hook(repository_full_name, hook.id, hooks)
155
+
62
156
  if primary_link && primary_link != link
63
157
  align_link_secret(link, primary_link.webhook_secret)
64
- :secret_aligned
158
+ if force_hook_refresh
159
+ update_webhook(
160
+ repository_full_name,
161
+ hook.id,
162
+ primary_link.webhook_secret
163
+ ) ? :updated : :failed
164
+ else
165
+ :secret_aligned
166
+ end
65
167
  else
66
168
  update_webhook(repository_full_name, hook.id, link.webhook_secret) ? :updated : :failed
67
169
  end
@@ -73,31 +175,478 @@ module CollavreGithub
73
175
  align_link_secret(link, secret)
74
176
  end
75
177
 
76
- create_webhook(repository_full_name, secret) ? :created : :failed
178
+ if shared
179
+ # Registered in this database, so the instance that created it reads
180
+ # the same RepositoryLink rows: same signing secret, same event list
181
+ # (`events_for` queries those rows). Reusing it is safe and is the
182
+ # only way to keep exactly one hook per repo.
183
+ #
184
+ # Its subscriptions are still refreshed. `events_for` widens to
185
+ # include `push` the moment any link enables markdown sync, and the
186
+ # sibling that owns the hook has no reason to reprovision — leaving
187
+ # it on the old list would let the initial sync run and then silently
188
+ # miss every later push. Only the URL is preserved: rewriting that to
189
+ # this host would break the sibling and start the two instances
190
+ # flipping it back and forth.
191
+ log_shared_hook_reuse(repository_full_name, shared)
192
+ return update_shared_webhook(repository_full_name, shared, secret) ? :shared : :failed
193
+ end
194
+
195
+ created = create_webhook(repository_full_name, secret)
196
+ return :failed unless created
197
+
198
+ created_id = created_hook_id(created)
199
+ # No id to register with (a Client that swallowed the error): the hook
200
+ # may well exist, so registration is deferred to the next run, which
201
+ # finds it by URL. Nothing to undo either way.
202
+ return :created if created_id.blank?
203
+ # Only a positively established sibling registration may undo the hook
204
+ # just created. `:unverified` means GitHub did not answer, and deleting
205
+ # a working hook on a guess would leave the repository with none at
206
+ # all — strictly worse than the duplicate delivery that keeping it may
207
+ # cost, which the GUID ledger collapses anyway.
208
+ registration = register_hook(repository_full_name, created_id, hooks)
209
+ return :created unless registration == :superseded
210
+
211
+ if force_hook_refresh
212
+ return :failed unless superseding_hook &&
213
+ update_shared_webhook(repository_full_name, superseding_hook, secret)
214
+ end
215
+
216
+ # A sibling instance created and registered its own hook while this one
217
+ # was creating its own. Both feed this database, so keeping both would
218
+ # double every delivery for good.
219
+ discard_duplicate_hook(repository_full_name, created_id)
220
+ :shared
221
+ end
222
+ end
223
+
224
+ def remove_webhook(repository_full_name)
225
+ hooks = repository_hooks(repository_full_name)
226
+ delete_legacy_hooks(repository_full_name, hooks)
227
+
228
+ hook = find_own_hook(hooks)
229
+ if hook
230
+ client.delete_repository_webhook(repository_full_name, hook.id)
231
+ end
232
+
233
+ # Only this instance's own hook is deleted. The registration that would
234
+ # identify a sibling lives on the repository's links, and this method
235
+ # only runs once the last of them is gone, so there is no longer any
236
+ # evidence of who created the remaining hooks. A hook on the same path
237
+ # under a different host may equally be a sibling instance or a separate
238
+ # deployment with its own database, for which this unlink says nothing —
239
+ # deleting it would silently break that deployment. It is reported, not
240
+ # removed.
241
+ leftover = find_same_path_hook(hooks)
242
+ if leftover
243
+ Rails.logger.info(
244
+ "[CollavreGithub] hook #{hook_url(leftover)} on #{repository_full_name} was left in " \
245
+ "place after unlink: its owner cannot be determined once the links are gone. " \
246
+ "Delete it manually if no deployment still needs it"
247
+ )
77
248
  end
78
249
  rescue Octokit::Error => e
79
250
  Rails.logger.warn(
80
- "GitHub webhook provisioning failed for #{repository_full_name}: #{e.message}"
251
+ "GitHub webhook removal failed for #{repository_full_name}: #{e.message}"
81
252
  )
82
- :failed
83
253
  end
84
254
 
85
- def remove_webhook(repository_full_name)
86
- hook = find_existing_hook(repository_full_name)
87
- return unless hook
255
+ def repository_hooks(repository_full_name)
256
+ Array(client.repository_hooks(repository_full_name))
257
+ end
258
+
259
+ def find_own_hook(hooks)
260
+ hooks.find { |hook| hook_url(hook) == webhook_url }
261
+ end
262
+
263
+ # A hook another instance of this app created, identified by the id it
264
+ # recorded on this repository's links.
265
+ #
266
+ # Registration is the only positive evidence of shared state, and that is
267
+ # what makes reuse safe. Matching on the URL path instead would classify a
268
+ # completely separate deployment's hook as reusable merely because it
269
+ # serves the same path — that deployment has its own database and its own
270
+ # webhook secret, so deferring to it would leave this instance receiving no
271
+ # deliveries at all. Matching on the full URL is the opposite failure and is
272
+ # the proliferation this fix targets: every instance saw its siblings' hooks
273
+ # as foreign and created its own, so N instances meant N hooks.
274
+ #
275
+ # `own_hook` is excluded so callers can distinguish "mine" from "theirs"
276
+ # when this instance is itself the registered owner.
277
+ #
278
+ # A hook on the legacy path is excluded too, however it came to be
279
+ # registered. This run deletes it, so reusing it as the replacement means
280
+ # patching it, reporting success, and then deleting the only hook the
281
+ # repository had — no replacement was ever created because this branch
282
+ # said one already existed.
283
+ def find_shared_hook(hooks, repository_full_name, own_hook)
284
+ registered = registered_hook_id(repository_full_name)
285
+ return nil if registered.blank?
286
+
287
+ hooks.find do |hook|
288
+ hook.id.to_s == registered.to_s &&
289
+ (own_hook.nil? || hook.id != own_hook.id) &&
290
+ !legacy_hook?(hook)
291
+ end
292
+ end
293
+
294
+ # Hooks this run will migrate off the deprecated singular path. Blank
295
+ # `legacy_webhook_path` (an unexpected `webhook_url`) matches nothing, so
296
+ # no hook is ever reclassified on a guess.
297
+ def legacy_hook?(hook)
298
+ return false if legacy_webhook_path.blank?
299
+
300
+ url_path(hook_url(hook)) == legacy_webhook_path
301
+ end
302
+
303
+ # Reporting only — never a reuse decision. See `remove_webhook`.
304
+ def find_same_path_hook(hooks)
305
+ hooks.find do |hook|
306
+ url = hook_url(hook)
307
+ url != webhook_url && url_path(url) == webhook_path
308
+ end
309
+ end
310
+
311
+ # Every primary-link, registration, and event lookup is bounded by the
312
+ # selected link's identity. ID-backed attempts use the stable repository id
313
+ # across every stored-name alias, including links that missed a rename.
314
+ # Name-only attempts remain limited to same-name NULL-id links: a row with
315
+ # an authoritative id under that reused name may belong to another
316
+ # repository and must not contribute its secret, registration, or settings.
317
+ def links_for(repository_full_name)
318
+ if provisioning_link&.repository_id.present?
319
+ return CollavreGithub::RepositoryLink.where(repository_id: provisioning_link.repository_id)
320
+ end
88
321
 
322
+ links = CollavreGithub::RepositoryLink
323
+ .where("LOWER(repository_full_name) = ?", normalize_repository_name(repository_full_name))
324
+ return links unless provisioning_link
325
+
326
+ links.where(repository_id: nil)
327
+ end
328
+
329
+ def normalize_repository_name(repository_full_name)
330
+ repository_full_name.to_s.downcase
331
+ end
332
+
333
+ def verified_removal_name(link)
334
+ return if link.repository_id.blank?
335
+
336
+ identity = client.repository_identity(link.repository_full_name)
337
+ return if identity&.id.blank? || identity&.full_name.blank?
338
+ return unless identity.id.to_s == link.repository_id.to_s
339
+
340
+ identity.full_name
341
+ rescue Octokit::Error, Faraday::Error => e
342
+ Rails.logger.warn(
343
+ "[CollavreGithub] webhook removal identity verification failed for " \
344
+ "#{link.repository_full_name}: #{e.class}: #{e.message}"
345
+ )
346
+ nil
347
+ end
348
+
349
+ def registered_hook_id(repository_full_name)
350
+ links_for(repository_full_name)
351
+ .where.not(webhook_hook_id: nil)
352
+ .order(:id)
353
+ .pick(:webhook_hook_id)
354
+ end
355
+
356
+ # Records the hook so sibling instances can recognise it. Written to every
357
+ # link for the repository, not just the primary one, so that deleting a
358
+ # link cannot lose the registration and let the next run create a duplicate.
359
+ #
360
+ # Skipped while the registered hook is still live on GitHub: overwriting a
361
+ # sibling's registration would let the two instances take turns claiming it
362
+ # and bring the proliferation straight back. A registration whose hook has
363
+ # since been deleted is stale and gets replaced.
364
+ #
365
+ # Returns what became of the registration, which is what tells the caller
366
+ # whether the hook it just created may stay:
367
+ # :recorded - this database's registration names `hook_id`
368
+ # :superseded - a sibling's registration stands; `hook_id` is redundant
369
+ # :unverified - GitHub could not confirm what the registration names, so
370
+ # nothing was written and nothing may be undone on the
371
+ # strength of it
372
+ #
373
+ # The caller needs that answer because the read above and the write below
374
+ # are not one operation: two instances provisioning the same repository at
375
+ # once both list no hooks, both create one, and both arrive here. The write
376
+ # is therefore a compare-and-set against the value just read, so only one
377
+ # can win — the loser's UPDATE matches nothing, because the row it is
378
+ # testing against has already moved. Without it the two writes were plain
379
+ # last-writer-wins and both hooks stayed live forever.
380
+ def register_hook(repository_full_name, hook_id, hooks)
381
+ return :unverified if hook_id.blank?
382
+
383
+ registered = registered_hook_id(repository_full_name)
384
+ if registered.to_s == hook_id.to_s
385
+ backfill_registration(repository_full_name, hook_id)
386
+ return :recorded
387
+ end
388
+ live = live_registered_hook(repository_full_name, registered, hooks)
389
+ return :unverified if live == :unknown
390
+
391
+ # A live registration naming a LEGACY hook is not a competitor to defer
392
+ # to: this run is replacing that hook, not racing a sibling for it.
393
+ # Deferring would discard the replacement just created and then delete the
394
+ # legacy hook, leaving the repository with none at all.
395
+ if live && !legacy_hook?(live)
396
+ @superseding_hook = live
397
+ return :superseded
398
+ end
399
+
400
+ links_for(repository_full_name)
401
+ .where(webhook_hook_id: [ nil, registered ].uniq)
402
+ .update_all(webhook_hook_id: hook_id)
403
+
404
+ # Re-read rather than trust the affected-row count: the rows are only
405
+ # "ours" if the registration now reads back as this hook.
406
+ if registered_hook_id(repository_full_name).to_s == hook_id.to_s
407
+ :recorded
408
+ else
409
+ :superseded
410
+ end
411
+ end
412
+
413
+ # A link created after the hook was registered starts with a null
414
+ # webhook_hook_id, and the compare-and-set above returns before writing
415
+ # anything — the value it compares against is already correct, so there is
416
+ # nothing to set. That leaves the registration on the older links only.
417
+ # Removing those links then takes the last copy of it with them, and the
418
+ # live hook becomes unrecognisable: `find_shared_hook` requires a recorded
419
+ # id, so the next run from a sibling host reads it as foreign and adds a
420
+ # second hook — the proliferation this registry exists to stop.
421
+ #
422
+ # Only null registrations are filled. A link naming a different hook is not
423
+ # this run's to overwrite; that case is the stale-vs-live question the CAS
424
+ # above already answers.
425
+ def backfill_registration(repository_full_name, hook_id)
426
+ links_for(repository_full_name)
427
+ .where(webhook_hook_id: nil)
428
+ .update_all(webhook_hook_id: hook_id)
429
+ end
430
+
431
+ # The registered hook as it exists on GitHub: the hook itself if it is
432
+ # live, nil if GitHub says it is gone, `:unknown` if GitHub could not be
433
+ # asked.
434
+ #
435
+ # `hooks` was listed before this run started creating anything, so a
436
+ # registration missing from it is NOT proof the hook is gone — a sibling
437
+ # instance may have created and registered it since. The two cases demand
438
+ # opposite actions (replace a stale registration; defer to a live one) and
439
+ # getting either wrong puts a second hook on the repository, so when the
440
+ # cached listing cannot vouch for it the question is put to GitHub.
441
+ #
442
+ # The hook itself is returned rather than a boolean: the caller also has to
443
+ # know whether it sits on the legacy path, which only its URL can say.
444
+ def live_registered_hook(repository_full_name, registered, hooks)
445
+ # Nothing registered, so there is no hook to vouch for and the re-listing
446
+ # below could only ever come back empty. Skipping it matters because this
447
+ # is the ordinary first provision of a repository, where paying for a
448
+ # second GitHub round trip buys nothing.
449
+ return nil if registered.blank?
450
+
451
+ found = hooks.find { |hook| hook.id.to_s == registered.to_s }
452
+ return found if found
453
+
454
+ verify_registered_hook(repository_full_name, registered)
455
+ end
456
+
457
+ # Deliberately NOT `repository_hooks`, which is the error-swallowing form
458
+ # and answers `[]` for a request GitHub never served. Here that empty list
459
+ # would be read as "the registered hook is gone" and the registration
460
+ # overwritten with this run's hook while the original stays live — and
461
+ # since the original's id is then recorded nowhere, no later run can even
462
+ # see it: `find_shared_hook` needs a registered id and `find_own_hook`
463
+ # matches only this instance's URL. The repository keeps delivering twice
464
+ # for good, with nothing left to report it. This is the one caller that
465
+ # must tell an empty answer from no answer.
466
+ def verify_registered_hook(repository_full_name, registered)
467
+ Array(client.repository_hooks!(repository_full_name))
468
+ .find { |hook| hook.id.to_s == registered.to_s }
469
+ rescue Octokit::Error, Faraday::Error => e
470
+ Rails.logger.warn(
471
+ "[CollavreGithub] could not verify registered hook #{registered} on " \
472
+ "#{repository_full_name}: #{e.message}; leaving the registration alone. " \
473
+ "The next provisioning run decides it with a listing that answered"
474
+ )
475
+ :unknown
476
+ end
477
+
478
+ # Undoes a hook this run created after losing the registration race above.
479
+ # Leaving it would put two live hooks on the repository permanently — the
480
+ # next run sees one registered hook and one of its own and can only warn,
481
+ # since deleting a hook it cannot prove it created is unsafe. Here that
482
+ # proof exists: this run created this hook seconds ago.
483
+ #
484
+ # A failure to delete is logged rather than raised. The hook is real and
485
+ # working, so the cost is a duplicate delivery, which the GUID ledger
486
+ # already collapses — not worth failing an otherwise successful provision.
487
+ def discard_duplicate_hook(repository_full_name, hook_id)
488
+ Rails.logger.info(
489
+ "[CollavreGithub] deleting hook #{hook_id} just created for #{repository_full_name}: " \
490
+ "another instance registered its own hook concurrently"
491
+ )
492
+ client.delete_repository_webhook(repository_full_name, hook_id)
493
+ rescue Octokit::Error => e
494
+ Rails.logger.warn(
495
+ "[CollavreGithub] could not delete redundant hook #{hook_id} on " \
496
+ "#{repository_full_name}: #{e.message}. Delete it manually to stop GitHub " \
497
+ "sending every delivery twice"
498
+ )
499
+ end
500
+
501
+ # Octokit returns the created hook; a Client that swallowed the error
502
+ # returns something without an id, in which case registration is simply
503
+ # deferred to the next run, which finds the hook by URL.
504
+ def created_hook_id(created)
505
+ created.id if created.respond_to?(:id)
506
+ end
507
+
508
+ # Migrates the repository off the deprecated singular `/github/webhook`
509
+ # path: alongside the plural hook it only produces a duplicate delivery,
510
+ # and removing it here is what eventually makes the alias route droppable.
511
+ def delete_legacy_hooks(repository_full_name, hooks, prior_registration = nil)
512
+ return if legacy_webhook_path.blank?
513
+
514
+ registered = [ prior_registration, registered_hook_id(repository_full_name) ]
515
+ .compact_blank
516
+ .map(&:to_s)
517
+ legacy = hooks.select { |hook| legacy_hook?(hook) }
518
+ mine, theirs = legacy.partition { |hook| own_legacy_hook?(hook, registered) }
519
+
520
+ theirs.each do |hook|
521
+ Rails.logger.info(
522
+ "[CollavreGithub] leaving legacy hook #{hook_url(hook)} on #{repository_full_name} " \
523
+ "in place: it is not registered here and sits under another host, so it may belong " \
524
+ "to an independent deployment still served by that route"
525
+ )
526
+ end
527
+
528
+ mine.each do |hook|
529
+ Rails.logger.info(
530
+ "[CollavreGithub] deleting legacy hook #{hook_url(hook)} on #{repository_full_name}"
531
+ )
532
+ delete_legacy_hook(repository_full_name, hook)
533
+ end
534
+ end
535
+
536
+ # Rescued for the same reason `discard_duplicate_hook` is: by the time this
537
+ # runs the replacement hook is already in place, so a failure to remove the
538
+ # deprecated one costs a duplicate delivery that the GUID ledger collapses.
539
+ # Letting it escape reaches `ensure_webhook`'s `rescue Octokit::Error` and
540
+ # turns a provision that actually succeeded into `:failed`, which
541
+ # `pr_monitor` reports as "GitHub API rejected the hook request" — a false
542
+ # alarm about the very hook that was just provisioned correctly.
543
+ #
544
+ # Defence in depth rather than a live bug: the injected production client
545
+ # (`CollavreGithub::Client`) rescues Octokit and Faraday itself and answers
546
+ # nil, so nothing raises this far today. That also makes the outer rescue
547
+ # unreachable in production, which is precisely why the cost of an
548
+ # unrescued raise here is invisible until a client that does raise is used.
549
+ def delete_legacy_hook(repository_full_name, hook)
89
550
  client.delete_repository_webhook(repository_full_name, hook.id)
90
551
  rescue Octokit::Error => e
91
552
  Rails.logger.warn(
92
- "GitHub webhook removal failed for #{repository_full_name}: #{e.message}"
553
+ "[CollavreGithub] could not delete legacy hook #{hook_url(hook)} on " \
554
+ "#{repository_full_name}: #{e.message}. It is redundant with the plural-path " \
555
+ "hook; delete it manually to stop GitHub sending every delivery twice"
93
556
  )
94
557
  end
95
558
 
96
- def find_existing_hook(repository_full_name)
97
- client.repository_hooks(repository_full_name).find do |hook|
98
- config = normalize_config(hook.config)
99
- config["url"] == webhook_url
559
+ # Deletion must rest on stronger evidence than reuse, not weaker. Matching
560
+ # the legacy PATH alone would delete the hook of an independent deployment
561
+ # that still serves `/github/webhook` — a supported route — and take its
562
+ # deliveries offline. Ownership is therefore positive: either the hook
563
+ # carries this instance's own host, or its id is registered in this
564
+ # database, which only an instance sharing this database could have written.
565
+ def own_legacy_hook?(hook, registered_ids)
566
+ return true if registered_ids.include?(hook.id.to_s)
567
+
568
+ same_origin?(hook_url(hook), webhook_url)
569
+ end
570
+
571
+ def same_origin?(url, other)
572
+ a = URI.parse(url)
573
+ b = URI.parse(other)
574
+ a.host.present? && a.host == b.host && a.port == b.port
575
+ rescue URI::InvalidURIError
576
+ false
577
+ end
578
+
579
+ def webhook_path
580
+ @webhook_path ||= url_path(webhook_url)
581
+ end
582
+
583
+ # `/github/webhooks` -> `/github/webhook`. Blank when `webhook_url` does not
584
+ # end in the expected segment, so an unexpected URL never causes an
585
+ # unrelated hook to be deleted.
586
+ def legacy_webhook_path
587
+ return @legacy_webhook_path if defined?(@legacy_webhook_path)
588
+
589
+ @legacy_webhook_path =
590
+ if webhook_path.end_with?("/#{ROUTE_SEGMENT}")
591
+ webhook_path.sub(/#{Regexp.escape(ROUTE_SEGMENT)}\z/, LEGACY_ROUTE_SEGMENT)
592
+ end
593
+ end
594
+
595
+ def hook_url(hook)
596
+ normalize_config(hook.config)["url"].to_s
597
+ end
598
+
599
+ # Reuse leaves this instance with no hook of its own: every delivery for the
600
+ # repository goes to whichever host created it. The registry is claimed
601
+ # first-come, so that host can be a laptop or a tunnel — and when it goes
602
+ # away GitHub keeps delivering to a dead URL while no instance receives
603
+ # anything. Nothing else reports that state: the hook exists, the links are
604
+ # linked, and the repository simply goes quiet.
605
+ #
606
+ # Reusing a hook on this instance's own origin carries none of that (the
607
+ # deliveries arrive here), so only the cross-host case is raised to `warn`,
608
+ # together with GitHub's own record of the last delivery to that URL. The
609
+ # listing already carries it, so this costs no extra API call and turns a
610
+ # state the setup guide can currently only tell operators to check by hand
611
+ # on github.com into something greppable in the logs.
612
+ def log_shared_hook_reuse(repository_full_name, shared)
613
+ url = hook_url(shared)
614
+ message = "[CollavreGithub] reusing registered hook #{url} " \
615
+ "for #{repository_full_name}; not creating #{webhook_url}"
616
+
617
+ if same_origin?(url, webhook_url)
618
+ Rails.logger.info(message)
619
+ return
100
620
  end
621
+
622
+ Rails.logger.warn(
623
+ "#{message}. This instance will receive no deliveries for that repository; " \
624
+ "if that host stops answering, none will. GitHub's last delivery to it: " \
625
+ "#{last_delivery_summary(shared)}"
626
+ )
627
+ end
628
+
629
+ # Present on every hook GitHub lists. Reported verbatim rather than
630
+ # interpreted: a failing last delivery is the signal an operator needs, but
631
+ # which shape GitHub records for an unreachable host is not something this
632
+ # code can establish, and acting on a guess would rewrite a sibling's URL.
633
+ def last_delivery_summary(hook)
634
+ response = hook.respond_to?(:last_response) ? hook.last_response : nil
635
+ response = normalize_config(response)
636
+ return "not reported" if response.blank?
637
+
638
+ summary = [ response["status"], response["code"] ].compact_blank.join(" ")
639
+ summary.presence || "not reported"
640
+ end
641
+
642
+ # Trailing slashes are insignificant to Rails routing but not to string
643
+ # comparison, so normalize them away before matching.
644
+ def url_path(url)
645
+ path = URI.parse(url).path.to_s
646
+ path = path.chomp("/") if path.length > 1
647
+ path
648
+ rescue URI::InvalidURIError
649
+ ""
101
650
  end
102
651
 
103
652
  def create_webhook(repository_full_name, secret)
@@ -121,18 +670,28 @@ module CollavreGithub
121
670
  )
122
671
  end
123
672
 
673
+ # Refreshes a sibling instance's hook while leaving its URL alone, so the
674
+ # deliveries keep flowing to whichever host created it.
675
+ def update_shared_webhook(repository_full_name, hook, secret)
676
+ client.update_repository_webhook(
677
+ repository_full_name,
678
+ hook.id,
679
+ url: hook_url(hook),
680
+ secret: secret,
681
+ events: events_for(repository_full_name),
682
+ content_type: CONTENT_TYPE
683
+ )
684
+ end
685
+
124
686
  def events_for(repository_full_name)
125
- has_markdown_sync = CollavreGithub::RepositoryLink
126
- .where(repository_full_name: repository_full_name, markdown_sync_enabled: true)
687
+ has_markdown_sync = links_for(repository_full_name)
688
+ .where(markdown_sync_enabled: true)
127
689
  .exists?
128
690
  has_markdown_sync ? EVENTS_WITH_PUSH : EVENTS
129
691
  end
130
692
 
131
693
  def primary_link_for(repository_full_name)
132
- CollavreGithub::RepositoryLink
133
- .where(repository_full_name: repository_full_name)
134
- .order(:id)
135
- .first
694
+ links_for(repository_full_name).order(:id).first
136
695
  end
137
696
 
138
697
  def align_link_secret(link, secret)