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
|
@@ -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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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.
|
|
18
|
-
new(account: account, webhook_url: webhook_url).
|
|
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(
|
|
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;
|
|
31
|
-
# RepositoryLink secret was aligned
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
251
|
+
"GitHub webhook removal failed for #{repository_full_name}: #{e.message}"
|
|
81
252
|
)
|
|
82
|
-
:failed
|
|
83
253
|
end
|
|
84
254
|
|
|
85
|
-
def
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 =
|
|
126
|
-
.where(
|
|
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
|
-
|
|
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)
|