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
@@ -10,10 +10,202 @@ module CollavreGithub
10
10
  raw_body = request.raw_post.presence || request.body.read
11
11
  payload = parse_payload(raw_body)
12
12
  @repository_link = find_repository_link(payload)
13
- return head :unauthorized unless valid_signature?(raw_body)
13
+ unless valid_signature?(raw_body)
14
+ # A rejected delivery is otherwise visible ONLY as a red row in
15
+ # GitHub's hook UI. On this side it leaves no trace at all: the
16
+ # deliveries ledger is written below, after verification. Everyone
17
+ # watching the topic just sees silence where PR comments should be.
18
+ #
19
+ # Announced ONLY when no secret could be resolved — i.e. the delivery
20
+ # named a repository this app cannot identify. That is the failure that
21
+ # is worth a human's attention (a rename, or a link that was never
22
+ # provisioned) and it is the one that stays broken until someone acts.
23
+ #
24
+ # A signature MISMATCH is deliberately silent. There the repository was
25
+ # identified and a secret was found; the request simply did not sign
26
+ # with it, which is what an attacker probing the endpoint looks like.
27
+ # Announcing that would hand any unauthenticated caller who can guess a
28
+ # monitored repository's name a way to post into that topic on demand.
29
+ announce_webhook_auth_failure(payload) if webhook_secret.blank?
30
+ return head :unauthorized
31
+ end
32
+
33
+ # NULL-id rows predate trustworthy repository identity storage. Their
34
+ # hook registration and secret may both have been copied across every
35
+ # same-name row by older provisioners, so even a valid HMAC cannot prove
36
+ # which row the current repository belongs to. Require the explicit
37
+ # reattachment workflow before processing or mutating such a link.
38
+ if @repository_link && @repository_link.repository_id.blank?
39
+ Rails.logger.warn(
40
+ "[CollavreGithub] rejecting delivery for unverified repository link " \
41
+ "#{@repository_link.id}; explicit identity verification is required"
42
+ )
43
+ return head :unauthorized
44
+ end
45
+
46
+ # Idempotency claim. Deliberately AFTER signature verification: the claim
47
+ # writes a row keyed by an attacker-supplied header, so an unauthenticated
48
+ # caller must not be able to pre-claim a GUID and have GitHub's real
49
+ # delivery dropped as a duplicate.
50
+ #
51
+ # `head :ok` on a lost claim (not an error status). An error status would
52
+ # mark a delivery failed that was in fact handled, and a failed delivery
53
+ # is the one thing an operator is asked to act on.
54
+ #
55
+ # Worth stating plainly, because several decisions below turn on it:
56
+ # GitHub NEVER redelivers automatically. A 4xx, a 5xx or a timeout is
57
+ # simply recorded as failed. Recovery is a human pressing Redeliver or a
58
+ # scheduled script walking the deliveries API, and only inside GitHub's
59
+ # 3-day window. So an error status here buys no automatic retry — it only
60
+ # spends the one signal that recovery depends on.
61
+ # The token, not the GUID, is this run's proof of ownership. Ownership can
62
+ # be taken away mid-run (see WebhookDelivery::STALE_CLAIM_AFTER), so both
63
+ # the release below and the mark_processed! at the end are scoped to it.
64
+ #
65
+ # The claim is lost in two states that this request cannot tell apart at
66
+ # the moment it answers: the owner has finished, or the owner is still
67
+ # running and may yet fail. Both answer 200, i.e. this branch ASSUMES the
68
+ # owner succeeds. That assumption is deliberate, and it is safe because it
69
+ # does not consume the owner's own recovery: a failing owner answers 5xx
70
+ # on ITS delivery, and each hook (and each Redeliver press) is a separate
71
+ # delivery with a separate response. So a lost claim never reduces
72
+ # recoverability below that of any other failed delivery.
73
+ #
74
+ # Answering a retryable status here instead would be strictly worse. Two
75
+ # hooks are fanned out in parallel and a delivery takes well under a
76
+ # second, so the loser overlaps the owner on essentially every event: one
77
+ # hook would show a permanent stream of failed deliveries for events that
78
+ # were handled correctly — destroying the very signal (a red delivery
79
+ # means look at me) that the release-and-redeliver recovery depends on.
80
+ # Waiting for processed_at fares no better: it holds the connection open
81
+ # against GitHub's ~10s delivery timeout while a different request works,
82
+ # and cannot separate a slow owner from a dead one — only
83
+ # STALE_CLAIM_AFTER does that, and it is far longer than the timeout.
84
+ #
85
+ # What the assumption does cost is diagnosability, so the two states are
86
+ # distinguished in the log: a Redeliver pressed while the owner is still
87
+ # running is answered 200 and, if that owner then fails, the event is left
88
+ # for the owner's 5xx to recover. Only the log says which of the two
89
+ # happened.
90
+ claim_token = CollavreGithub::WebhookDelivery.claim(delivery_guid, event: event)
91
+ unless claim_token
92
+ # Block form: `claim_state_for_log` costs a query, and this branch is
93
+ # taken on nearly every event once a repository carries two hooks, so it
94
+ # must not run when info logging is off.
95
+ Rails.logger.info do
96
+ "[CollavreGithub] duplicate delivery #{delivery_guid} (#{event}); skipping " \
97
+ "(#{claim_state_for_log})"
98
+ end
99
+ return head :ok
100
+ end
101
+
102
+ begin
103
+ process_delivery(event, payload)
104
+ rescue => e
105
+ # The claim must not outlive a failed run. This request answers 5xx,
106
+ # and any redelivery that follows — always operator- or script-driven,
107
+ # never automatic — carries the SAME GUID. With the row still in
108
+ # place that redelivery would take the duplicate branch above, answer
109
+ # 200 and drop the event permanently, which is strictly worse than the
110
+ # duplicate this ledger exists to prevent.
111
+ #
112
+ # Processing is therefore at-least-once: a run that fails halfway can
113
+ # repeat the side effects it already performed. Wrapping the whole
114
+ # delivery in one transaction would make it exactly-once but would
115
+ # break `dispatch_to_channels`, which isolates a broken channel with a
116
+ # rescue — under Postgres a rescued statement error leaves the
117
+ # enclosing transaction aborted, so every sibling channel would fail
118
+ # too. Repeating a comment beats losing the event and taking every
119
+ # other channel down with it.
120
+ Rails.logger.error(
121
+ "[CollavreGithub] delivery #{delivery_guid} (#{event}) failed, releasing claim: #{e.class}: #{e.message}"
122
+ )
123
+ CollavreGithub::WebhookDelivery.release(delivery_guid, claim_token)
124
+ raise
125
+ end
126
+
127
+ complete_delivery(event, claim_token)
128
+ head :ok
129
+ rescue JSON::ParserError
130
+ head :bad_request
131
+ end
132
+
133
+ private
14
134
 
135
+ # Why the claim was lost, for the log line only — never for the response,
136
+ # which is 200 in every case. Read separately from `claim` because the
137
+ # answer is advisory: the owner can finish between the failed claim and
138
+ # this read, and a state that is already stale is still the closest thing
139
+ # to an answer available to a request that owns nothing.
140
+ #
141
+ # `released` is the state worth grepping for. It means the owner failed and
142
+ # dropped the claim, so this delivery was dismissed with nobody left
143
+ # holding the event — recovery rests entirely on the owner's own 5xx.
144
+ def claim_state_for_log
145
+ delivery = CollavreGithub::WebhookDelivery.find_by(delivery_guid: delivery_guid)
146
+ return "released by a failed owner" if delivery.nil?
147
+
148
+ delivery.processed_at ? "already processed" : "owner still in flight"
149
+ end
150
+
151
+ # Bounded because the ledger write is a single UPDATE on a row this run
152
+ # already owns: it either goes through on a retry or the database is gone,
153
+ # and spinning would only hold the request open while GitHub times out.
154
+ MARK_PROCESSED_ATTEMPTS = 3
155
+
156
+ # Stamping the ledger is part of completing the delivery, not an epilogue
157
+ # to it. Left outside the failure handling, a transient database error here
158
+ # stranded the claim unprocessed *after* every side effect had already run,
159
+ # and a redelivery arriving past STALE_CLAIM_AFTER took the row over and
160
+ # repeated all of them.
161
+ #
162
+ # A failure that outlives the retries does NOT release the claim, which is
163
+ # where this parts from the rescue around `process_delivery`. There the
164
+ # event was never handled, so freeing the GUID is what saves it. Here the
165
+ # event *was* handled: releasing would hand the next redelivery a clean
166
+ # slate and guarantee the repeat this is meant to avoid, while keeping the
167
+ # claim leaves a redelivery inside the stale window correctly dismissed.
168
+ #
169
+ # For the same reason the request still answers 200. Processing succeeded —
170
+ # only our bookkeeping did not — and a 5xx here would invite the very
171
+ # redelivery whose side effects we cannot afford to run twice.
172
+ def complete_delivery(event, claim_token)
173
+ attempts = 0
174
+ begin
175
+ attempts += 1
176
+ CollavreGithub::WebhookDelivery.mark_processed!(delivery_guid, claim_token)
177
+ rescue => e
178
+ retry if attempts < MARK_PROCESSED_ATTEMPTS
179
+
180
+ Rails.logger.error(
181
+ "[CollavreGithub] delivery #{delivery_guid} (#{event}) was processed but could not be " \
182
+ "marked processed after #{attempts} attempts; claim kept to keep a redelivery inside " \
183
+ "#{CollavreGithub::WebhookDelivery::STALE_CLAIM_AFTER.inspect} deduplicated: #{e.class}: #{e.message}"
184
+ )
185
+ end
186
+ end
187
+
188
+ def process_delivery(event, payload)
15
189
  payload = payload.presence || {}
16
190
 
191
+ # Rename first, and terminally: the whole point is to repair the routing
192
+ # keys before anything downstream reads them.
193
+ if event == "repository" && payload["action"] == "renamed"
194
+ handle_repository_renamed(payload)
195
+ return
196
+ end
197
+
198
+ repair_repository_identity_from_verified_delivery(payload)
199
+
200
+ # Other `repository` actions (archived, publicized, transferred, ...) can
201
+ # still repair a missed rename through the verified stable id, but are
202
+ # deliberately dropped rather than formatted into the creative feed.
203
+ # Subscribing to this event is a routing-maintenance concern, not a feed
204
+ # feature.
205
+ return if event == "repository"
206
+
207
+ clear_auth_failure_notices(payload)
208
+
17
209
  # Process all links for this repo (same repo can be linked to multiple creatives)
18
210
  all_links = all_repository_links_for(payload)
19
211
  all_links.each do |link|
@@ -23,14 +215,8 @@ module CollavreGithub
23
215
 
24
216
  maybe_auto_attach_channel(event, payload)
25
217
  dispatch_to_channels(event, payload)
26
-
27
- head :ok
28
- rescue JSON::ParserError
29
- head :bad_request
30
218
  end
31
219
 
32
- private
33
-
34
220
  # `WebhookProvisioner` auto-subscribes every repo webhook to the events
35
221
  # GithubPrChannel needs (`issue_comment`, `pull_request_review`,
36
222
  # `pull_request_review_comment`). Those events must only reach attached
@@ -57,7 +243,7 @@ module CollavreGithub
57
243
  # below would short-circuit and dispatch_to_channels (.active scope) would
58
244
  # skip the dismissed/detached row, leaving the chip hidden and the PR
59
245
  # unmonitored for the rest of its reopened life.
60
- reactivate_existing_channels_on_reopen(repo, pr_number) if payload["action"] == "reopened"
246
+ reactivate_existing_channels_on_reopen(repo, pr_number, payload) if payload["action"] == "reopened"
61
247
 
62
248
  # Body-link path: only used to CREATE a new channel. Existing-channel paths
63
249
  # are intentionally handled above (reopened) or as a strict no-op (opened
@@ -74,12 +260,15 @@ module CollavreGithub
74
260
  # topic and have subsequent PR comments injected there. Only auto-attach
75
261
  # when the topic's creative — or any of its ancestors — is linked to this
76
262
  # repo (RepositoryLink applies to the whole subtree).
77
- linked_creative_ids = CollavreGithub::RepositoryLink
78
- .where("LOWER(repository_full_name) = ?", repo).pluck(:creative_id)
263
+ linked_creative_ids = all_repository_links_for(payload).map(&:creative_id)
79
264
  creative = topic.creative
80
- return unless creative
81
- candidate_ids = [ creative.id ] + creative.ancestors.pluck(:id)
82
- return if (linked_creative_ids & candidate_ids).empty?
265
+ repository_id, = repository_identity(payload)
266
+ return unless creative_in_repo_scope?(
267
+ creative,
268
+ linked_creative_ids,
269
+ full_name: repo,
270
+ repository_id: repository_id
271
+ )
83
272
 
84
273
  existing = GithubPrChannel.where(topic_id: topic.id).find do |c|
85
274
  c.repo_full_name.to_s.downcase == repo && c.pr_number == pr_number
@@ -102,14 +291,14 @@ module CollavreGithub
102
291
  # reopened`, regardless of whether the PR description currently contains a
103
292
  # topic link. Mirrors dispatch's scope re-validation so a channel whose
104
293
  # creative is no longer linked to this repo is NOT resurrected.
105
- def reactivate_existing_channels_on_reopen(repo, pr_number)
106
- linked_creative_ids = CollavreGithub::RepositoryLink
107
- .where("LOWER(repository_full_name) = ?", repo).pluck(:creative_id)
294
+ def reactivate_existing_channels_on_reopen(repo, pr_number, payload)
295
+ linked_creative_ids = all_repository_links_for(payload).map(&:creative_id)
108
296
  return if linked_creative_ids.empty?
109
297
 
298
+ repository_id, = repository_identity(payload)
110
299
  GithubPrChannel.find_each do |channel|
111
300
  next unless channel.repo_full_name.to_s.downcase == repo && channel.pr_number == pr_number
112
- next unless channel_in_repo_scope?(channel, linked_creative_ids)
301
+ next unless channel_in_repo_scope?(channel, linked_creative_ids, repository_id: repository_id)
113
302
 
114
303
  begin
115
304
  # Row-level lock + re-read guards against duplicate reopened announcements
@@ -156,17 +345,17 @@ module CollavreGithub
156
345
  # RepositoryLink can be removed or a topic can be moved to a different
157
346
  # creative subtree after attachment. Without re-validating here, an
158
347
  # orphaned channel would keep receiving cross-tenant PR events.
159
- linked_creative_ids = CollavreGithub::RepositoryLink
160
- .where("LOWER(repository_full_name) = ?", repo).pluck(:creative_id)
348
+ linked_creative_ids = all_repository_links_for(payload).map(&:creative_id)
161
349
 
162
350
  # Ruby-level filter for DB portability (SQLite dev/test, Postgres prod).
163
351
  # Future optimization: switch to a jsonb-portable query once an established
164
352
  # pattern exists in this codebase. Compare repo names case-insensitively
165
353
  # so legacy mixed-case rows continue to match the canonical lowercase
166
354
  # payload value.
355
+ repository_id, = repository_identity(payload)
167
356
  GithubPrChannel.active.find_each do |channel|
168
357
  next unless channel.repo_full_name.to_s.downcase == repo && channel.pr_number == pr_number
169
- next unless channel_in_repo_scope?(channel, linked_creative_ids)
358
+ next unless channel_in_repo_scope?(channel, linked_creative_ids, repository_id: repository_id)
170
359
 
171
360
  begin
172
361
  # Row-level lock + re-check guards against duplicate dispatch when the
@@ -199,13 +388,30 @@ module CollavreGithub
199
388
  # still listed in a RepositoryLink for the webhook's repo. Mirrors the
200
389
  # auto-attach guard so removing a link severs the dispatch, not just the
201
390
  # ability to create new monitors.
202
- def channel_in_repo_scope?(channel, linked_creative_ids)
391
+ def channel_in_repo_scope?(channel, linked_creative_ids, repository_id:)
203
392
  return false if linked_creative_ids.empty?
204
393
 
205
394
  creative = channel.topic&.creative
395
+ creative_in_repo_scope?(
396
+ creative,
397
+ linked_creative_ids,
398
+ full_name: channel.repo_full_name,
399
+ repository_id: repository_id
400
+ )
401
+ end
402
+
403
+ def creative_in_repo_scope?(creative, linked_creative_ids, full_name:, repository_id:)
206
404
  return false unless creative
405
+ return false if CollavreGithub::RepositoryLink.conflicting_repository_in_scope?(
406
+ creative: creative,
407
+ full_name: full_name,
408
+ repository_id: repository_id
409
+ )
207
410
 
208
411
  candidate_ids = [ creative.id ] + creative.ancestors.pluck(:id)
412
+ excluded_ids = @identity_repair_excluded_creative_ids || []
413
+ return false if (excluded_ids & candidate_ids).any?
414
+
209
415
  (linked_creative_ids & candidate_ids).any?
210
416
  end
211
417
 
@@ -407,13 +613,34 @@ module CollavreGithub
407
613
  )
408
614
  end
409
615
 
616
+ # [github_repository_id, lowercased_full_name] from a payload that may use
617
+ # either string or symbol keys depending on how it was parsed.
618
+ def repository_identity(payload)
619
+ repo = payload&.dig("repository") || payload&.dig(:repository)
620
+ return [ nil, nil ] if repo.blank?
621
+
622
+ [
623
+ repo["id"] || repo[:id],
624
+ (repo["full_name"] || repo[:full_name]).to_s.downcase.presence
625
+ ]
626
+ end
627
+
628
+ def links_for(payload)
629
+ id, full_name = repository_identity(payload)
630
+ CollavreGithub::RepositoryLink.for_repository(id: id, full_name: full_name)
631
+ end
632
+
410
633
  def all_repository_links_for(payload)
411
- repo = payload&.dig("repository", "full_name") || payload&.dig(:repository, :full_name)
412
- return [ @repository_link ].compact if repo.blank?
634
+ id, full_name = repository_identity(payload)
635
+ return [ @repository_link ].compact if id.blank? && full_name.blank?
413
636
 
414
- CollavreGithub::RepositoryLink
415
- .where("LOWER(repository_full_name) = ?", repo.downcase)
416
- .to_a
637
+ links = links_for(payload).to_a
638
+ return links if id.blank?
639
+
640
+ links.select! { |link| link.repository_id.to_s == id.to_s }
641
+ return links if @identity_repair_link_ids.nil?
642
+
643
+ links.select { |link| @identity_repair_link_ids.include?(link.id) }
417
644
  end
418
645
 
419
646
  def find_repository_link(payload)
@@ -422,21 +649,309 @@ module CollavreGithub
422
649
  return
423
650
  end
424
651
 
425
- repo = payload["repository"] || payload[:repository]
426
- if repo.blank?
427
- Rails.logger.warn("[GitHub Webhook] Repository missing in payload")
652
+ id, full_name = repository_identity(payload)
653
+ if id.blank? && full_name.blank?
654
+ Rails.logger.warn("[GitHub Webhook] Repository identity missing in payload")
428
655
  return
429
656
  end
430
657
 
431
- full_name = repo["full_name"] || repo[:full_name]
432
- if full_name.blank?
433
- Rails.logger.warn("[GitHub Webhook] Repository full_name missing in payload")
434
- return
658
+ candidates = links_for(payload)
659
+ # A NULL-id name match is only a legacy candidate. Prefer the stable ID
660
+ # row for signature lookup so a stale same-name row with another secret
661
+ # cannot make a legitimate delivery fail depending on database order.
662
+ authoritative = candidates.where(repository_id: id).first
663
+ return authoritative if authoritative
664
+
665
+ candidates.first
666
+ end
667
+
668
+ # A verified delivery proves the current canonical name whenever its stable
669
+ # repository id matches the link used for HMAC verification. Repair that
670
+ # exact ID scope before ordinary fan-out resolves channel names.
671
+ def repair_repository_identity_from_verified_delivery(payload)
672
+ id, full_name = repository_identity(payload)
673
+ return if id.blank? || full_name.blank?
674
+
675
+ verified_by_id = @repository_link&.repository_id.present? &&
676
+ @repository_link.repository_id.to_s == id.to_s
677
+ return unless verified_by_id
678
+
679
+ repair_scope = CollavreGithub::RepositoryLink
680
+ .where(repository_id: id)
681
+ .pluck(:id, :repository_full_name)
682
+ return unless repair_scope.any? { |_link_id, name| name.to_s != full_name }
683
+
684
+ synchronized = CollavreGithub::RepositoryIdentitySynchronizer.call(
685
+ anchor: @repository_link,
686
+ repository_id: id,
687
+ full_name: full_name,
688
+ trusted_secret: webhook_secret
689
+ )
690
+ canonical_link_ids = CollavreGithub::RepositoryLink
691
+ .where(repository_id: id, repository_full_name: full_name)
692
+ .pluck(:id)
693
+ @identity_repair_link_ids = synchronized.map(&:id) & canonical_link_ids
694
+ @identity_repair_excluded_creative_ids = CollavreGithub::RepositoryLink
695
+ .where(repository_id: id)
696
+ .where.not(repository_full_name: full_name)
697
+ .distinct
698
+ .pluck(:creative_id)
699
+ @repository_link = synchronized.find do |link|
700
+ link.creative_id == @repository_link.creative_id
701
+ end || @repository_link
702
+ end
703
+
704
+ # GitHub sends `repository.renamed` with the NEW `full_name` and only the
705
+ # short name of the old one, so the previous full name has to be rebuilt
706
+ # from the owner login. The id is matched too, and matters more: it is what
707
+ # lets a link that was already backfilled be found even when its stored
708
+ # name matches nothing in the payload.
709
+ #
710
+ # A link whose `repository_id` is still NULL cannot be repaired by this
711
+ # handler. It is rejected before processing and requires the explicit
712
+ # identity reattachment workflow.
713
+ def handle_repository_renamed(payload)
714
+ repo = payload["repository"] || {}
715
+ new_full_name = (repo["full_name"] || repo[:full_name]).to_s
716
+ return if new_full_name.blank?
717
+
718
+ repo_id = repo["id"] || repo[:id]
719
+ owner = repo.dig("owner", "login")
720
+ from = payload.dig("changes", "repository", "name", "from")
721
+ old_full_name = ("#{owner}/#{from}" if owner.present? && from.present?)
722
+
723
+ CollavreGithub::RepositoryProvisioningLock.with_repository_name_lock(new_full_name) do
724
+ CollavreGithub::RepositoryLink.transaction do
725
+ synchronize_repository_rename!(
726
+ repository_id: repo_id,
727
+ old_full_name: old_full_name,
728
+ new_full_name: new_full_name
729
+ )
730
+ end
731
+ end
732
+ Rails.logger.info("[CollavreGithub] repository renamed #{old_full_name.inspect} -> #{new_full_name}")
733
+ end
734
+
735
+ def synchronize_repository_rename!(repository_id:, old_full_name:, new_full_name:)
736
+ # An authenticated rename must never mutate every row carrying the old
737
+ # name. GitHub allows that name to be reused by another repository after
738
+ # the rename, and a delayed/redelivered event would otherwise capture the
739
+ # new repository's links and channels too.
740
+ #
741
+ # ID-backed rows are authoritative. NULL-id rows require explicit
742
+ # reattachment because older provisioners may have copied both their hook
743
+ # registration and secret from an unrelated same-name repository.
744
+ links = if repository_id.present?
745
+ CollavreGithub::RepositoryLink.where(repository_id: repository_id).to_a
746
+ else
747
+ []
748
+ end
749
+
750
+ renamed_creative_ids = []
751
+ stored_names_by_creative = Hash.new { |hash, key| hash[key] = [] }
752
+ links.each do |link|
753
+ stored_full_name = link.repository_full_name.to_s
754
+ collision_resolution = resolve_repository_rename_collision(
755
+ link: link,
756
+ new_full_name: new_full_name,
757
+ repository_id: repository_id
758
+ )
759
+ unless collision_resolution.nil?
760
+ if collision_resolution
761
+ renamed_creative_ids << link.creative_id
762
+ stored_names_by_creative[stored_full_name.downcase] << link.creative_id
763
+ end
764
+ next
765
+ end
766
+
767
+ if link.repository_full_name == new_full_name &&
768
+ link.repository_id.to_s == repository_id.to_s
769
+ renamed_creative_ids << link.creative_id
770
+ next
771
+ end
772
+
773
+ conflicting_link = CollavreGithub::RepositoryLink.identity_conflict_in_link_scope(
774
+ creative: link.creative,
775
+ full_name: new_full_name,
776
+ repository_id: repository_id,
777
+ excluding_id: link.id
778
+ )
779
+ if conflicting_link
780
+ Rails.logger.warn(
781
+ "[CollavreGithub] rename collision for link #{link.id}: #{new_full_name} in " \
782
+ "creative #{link.creative_id} belongs to repository #{conflicting_link.repository_id.inspect}"
783
+ )
784
+ next
785
+ end
786
+
787
+ begin
788
+ CollavreGithub::RepositoryLink.transaction(requires_new: true) do
789
+ link.update_columns(repository_full_name: new_full_name, repository_id: repository_id)
790
+ end
791
+ renamed_creative_ids << link.creative_id
792
+ stored_names_by_creative[stored_full_name.downcase] << link.creative_id
793
+ rescue ActiveRecord::RecordNotUnique
794
+ if resolve_repository_rename_collision(
795
+ link: link,
796
+ new_full_name: new_full_name,
797
+ repository_id: repository_id
798
+ )
799
+ renamed_creative_ids << link.creative_id
800
+ stored_names_by_creative[stored_full_name.downcase] << link.creative_id
801
+ end
802
+ end
803
+ end
804
+
805
+ if old_full_name
806
+ stored_names_by_creative[old_full_name.downcase].concat(renamed_creative_ids)
807
+ end
808
+ stored_names_by_creative.each do |stored_name, creative_ids|
809
+ rename_channels(stored_name, new_full_name, creative_ids.uniq, repository_id)
810
+ end
811
+ end
812
+
813
+ def resolve_repository_rename_collision(link:, new_full_name:, repository_id:)
814
+ collisions = CollavreGithub::RepositoryLink
815
+ .where(creative_id: link.creative_id)
816
+ .where.not(id: link.id)
817
+ .where("LOWER(repository_full_name) = ?", new_full_name.downcase)
818
+ .to_a
819
+ return if collisions.empty?
820
+
821
+ conflict = collisions.find { |candidate| candidate.repository_id.to_s != repository_id.to_s }
822
+ if conflict
823
+ Rails.logger.warn(
824
+ "[CollavreGithub] rename collision for link #{link.id}: #{new_full_name} in " \
825
+ "creative #{link.creative_id} belongs to repository #{conflict.repository_id.inspect}"
826
+ )
827
+ return false
828
+ end
829
+
830
+ survivor = if link.repository_full_name == new_full_name
831
+ link
832
+ else
833
+ collisions.find { |candidate| candidate.repository_full_name == new_full_name } || collisions.first
834
+ end
835
+ ([ link ] + collisions).uniq.each do |candidate|
836
+ next if candidate == survivor
837
+
838
+ merge_repository_links!(obsolete: candidate, survivor: survivor)
839
+ end
840
+ survivor.update_columns(repository_full_name: new_full_name)
841
+ true
842
+ end
843
+
844
+ # A delayed rename can meet a link that was already created under the new
845
+ # name in the same creative. Leaving both rows behind would make every
846
+ # subsequent ID lookup process that creative twice. Keep the new-name row,
847
+ # align it with the secret that authenticated the delivery, preserve the
848
+ # obsolete row's sync/registration state where the survivor has none, and
849
+ # remove the duplicate.
850
+ def merge_repository_links!(obsolete:, survivor:)
851
+ survivor.update!(
852
+ webhook_secret: webhook_secret,
853
+ webhook_hook_id: survivor.webhook_hook_id || obsolete.webhook_hook_id,
854
+ markdown_sync_enabled: survivor.markdown_sync_enabled? || obsolete.markdown_sync_enabled?,
855
+ markdown_root_creative_id: survivor.markdown_root_creative_id || obsolete.markdown_root_creative_id,
856
+ sync_branch: survivor.sync_branch.presence || obsolete.sync_branch,
857
+ last_synced_at: [ survivor.last_synced_at, obsolete.last_synced_at ].compact.max
858
+ )
859
+ obsolete.destroy!
860
+ end
861
+
862
+ # Channels carry their own copy of the repo name (dispatch matches on it),
863
+ # so renaming the links alone would leave every attached PR chip orphaned.
864
+ def rename_channels(old_full_name, new_full_name, renamed_creative_ids, repository_id)
865
+ old = old_full_name.downcase
866
+ GithubPrChannel.find_each do |channel|
867
+ next unless channel.repo_full_name.to_s.downcase == old
868
+ next unless channel_in_repo_scope?(
869
+ channel,
870
+ renamed_creative_ids,
871
+ repository_id: repository_id
872
+ )
873
+
874
+ channel.synchronize_repository_name!(new_full_name)
875
+ end
876
+ end
877
+
878
+ # How long a channel stays quiet after announcing a rejected delivery.
879
+ # GitHub retries nothing, but a busy repository produces a steady stream of
880
+ # events and every one of them fails the same way — without this the topic
881
+ # would fill with identical warnings.
882
+ AUTH_FAILURE_NOTICE_INTERVAL = 1.hour
883
+
884
+ # Global guard on the channel query below. This path is reachable by
885
+ # unauthenticated callers, so the payload's repository name must not be part
886
+ # of the throttle key: otherwise a caller can cycle arbitrary names to force
887
+ # one query and one cache entry per request. A short global interval bounds
888
+ # the work while still letting independently broken repositories announce
889
+ # promptly. `unless_exist` makes concurrent claims atomic on cache stores
890
+ # that support it; NullStore fails open and preserves the warning.
891
+ AUTH_FAILURE_SCAN_INTERVAL = 1.minute
892
+ AUTH_FAILURE_SCAN_CACHE_KEY = "collavre_github:auth_failure_scan".freeze
893
+
894
+ # Announce a rejected delivery in every topic already monitoring the name
895
+ # carried by the payload. A stale ID-backed link is repaired after HMAC
896
+ # verification. A NULL-id link requires explicit reattachment because its
897
+ # historical hook registration and secret are not row identity.
898
+ #
899
+ # The payload is UNVERIFIED here — that is the whole situation — so nothing
900
+ # from it reaches the message. The repo name rendered is the channel's own
901
+ # stored value, and a channel is only selected when that value already
902
+ # equals the payload's. An attacker can therefore cause a canned warning in
903
+ # topics that are already monitoring the repository they named, at most
904
+ # once an hour each, and can inject no content of their own.
905
+ def announce_webhook_auth_failure(payload)
906
+ _id, repo = repository_identity(payload)
907
+ return if repo.blank?
908
+
909
+ claimed = Rails.cache.write(
910
+ AUTH_FAILURE_SCAN_CACHE_KEY,
911
+ true,
912
+ expires_in: AUTH_FAILURE_SCAN_INTERVAL,
913
+ unless_exist: true
914
+ )
915
+ return unless claimed
916
+
917
+ GithubPrChannel.active.where("LOWER(repo_full_name) = ?", repo).find_each do |channel|
918
+ begin
919
+ channel.with_lock do
920
+ last = channel.config["auth_failure_notified_at"]
921
+ next if last.present? && Time.zone.parse(last) > AUTH_FAILURE_NOTICE_INTERVAL.ago
922
+
923
+ # Marked before injecting: a failure while injecting must not leave
924
+ # the channel free to retry the same warning on the next delivery,
925
+ # which for a busy repo is seconds away.
926
+ channel.update!(config: channel.config.merge("auth_failure_notified_at" => Time.current.iso8601))
927
+ channel.inject_into_topic!(channel.auth_failure_message)
928
+ end
929
+ rescue => e
930
+ Rails.logger.error(
931
+ "[CollavreGithub] auth failure notice failed for channel #{channel.id}: #{e.class}: #{e.message}"
932
+ )
933
+ end
435
934
  end
935
+ end
436
936
 
437
- CollavreGithub::RepositoryLink
438
- .where("LOWER(repository_full_name) = ?", full_name.downcase)
439
- .first
937
+ # Clear the marker once deliveries verify again, so a repository that
938
+ # breaks, is repaired, then breaks again months later warns the second time
939
+ # too. Left set, the interval above would be the only thing re-arming it.
940
+ def clear_auth_failure_notices(payload)
941
+ _id, repo = repository_identity(payload)
942
+ return if repo.blank?
943
+
944
+ GithubPrChannel.where("LOWER(repo_full_name) = ?", repo).find_each do |channel|
945
+ next if channel.config["auth_failure_notified_at"].blank?
946
+
947
+ begin
948
+ channel.update!(config: channel.config.except("auth_failure_notified_at"))
949
+ rescue => e
950
+ Rails.logger.error(
951
+ "[CollavreGithub] clearing auth failure notice failed for channel #{channel.id}: #{e.class}: #{e.message}"
952
+ )
953
+ end
954
+ end
440
955
  end
441
956
 
442
957
  def valid_signature?(raw_body)
@@ -465,8 +980,13 @@ module CollavreGithub
465
980
  ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature_header)
466
981
  end
467
982
 
983
+ # Memoized: a rejected delivery reads this twice — once to verify, once to
984
+ # decide whether the rejection is worth announcing — and the fallback path
985
+ # queries integration settings.
468
986
  def webhook_secret
469
- @repository_link&.webhook_secret || fallback_webhook_secret
987
+ return @webhook_secret if defined?(@webhook_secret)
988
+
989
+ @webhook_secret = @repository_link&.webhook_secret || fallback_webhook_secret
470
990
  end
471
991
 
472
992
  def fallback_webhook_secret
@@ -502,5 +1022,10 @@ module CollavreGithub
502
1022
  request.headers["X-GitHub-Event"].presence ||
503
1023
  request.get_header("HTTP_X_GITHUB_EVENT").presence
504
1024
  end
1025
+
1026
+ def delivery_guid
1027
+ request.headers["X-GitHub-Delivery"].presence ||
1028
+ request.get_header("HTTP_X_GITHUB_DELIVERY").presence
1029
+ end
505
1030
  end
506
1031
  end