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
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
412
|
-
return [ @repository_link ].compact if
|
|
634
|
+
id, full_name = repository_identity(payload)
|
|
635
|
+
return [ @repository_link ].compact if id.blank? && full_name.blank?
|
|
413
636
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
426
|
-
if
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
@
|
|
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
|