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
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreGithub
|
|
4
|
+
# Trims the webhook idempotency ledger. Rows are only useful for as long as
|
|
5
|
+
# GitHub could still redeliver the same GUID; past that they are dead weight.
|
|
6
|
+
class WebhookDeliveryPruneJob < ApplicationJob
|
|
7
|
+
queue_as :default
|
|
8
|
+
|
|
9
|
+
def perform
|
|
10
|
+
deleted = CollavreGithub::WebhookDelivery.prune!
|
|
11
|
+
Rails.logger.info("[CollavreGithub::WebhookDeliveryPruneJob] pruned #{deleted} rows") if deleted.positive?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -2,6 +2,8 @@ module CollavreGithub
|
|
|
2
2
|
class GithubPrChannel < Collavre::Channel
|
|
3
3
|
self.table_name = "channels"
|
|
4
4
|
|
|
5
|
+
after_destroy_commit :broadcast_chips_changed
|
|
6
|
+
|
|
5
7
|
def repo_full_name
|
|
6
8
|
config["repo_full_name"]
|
|
7
9
|
end
|
|
@@ -58,6 +60,39 @@ module CollavreGithub
|
|
|
58
60
|
self.config = config.merge("pr_state" => value)
|
|
59
61
|
end
|
|
60
62
|
|
|
63
|
+
# Keep a single monitor when identity repair meets a channel that a user
|
|
64
|
+
# already attached under the canonical repository name. The explicit
|
|
65
|
+
# canonical channel survives; topic comments are not owned by channels, so
|
|
66
|
+
# removing the obsolete routing row loses no timeline content.
|
|
67
|
+
def synchronize_repository_name!(canonical_full_name)
|
|
68
|
+
return self if repo_full_name == canonical_full_name
|
|
69
|
+
|
|
70
|
+
survivor = self.class
|
|
71
|
+
.where(topic_id: topic_id, pr_number: pr_number)
|
|
72
|
+
.where("LOWER(repo_full_name) = ?", canonical_full_name.to_s.downcase)
|
|
73
|
+
.where.not(id: id)
|
|
74
|
+
.first
|
|
75
|
+
|
|
76
|
+
if survivor
|
|
77
|
+
if preferred_over?(survivor)
|
|
78
|
+
self.class.transaction do
|
|
79
|
+
survivor.destroy!
|
|
80
|
+
update!(config: config.merge("repo_full_name" => canonical_full_name))
|
|
81
|
+
end
|
|
82
|
+
return self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
survivor.update!(
|
|
86
|
+
config: survivor.config.merge("repo_full_name" => canonical_full_name)
|
|
87
|
+
)
|
|
88
|
+
destroy!
|
|
89
|
+
survivor
|
|
90
|
+
else
|
|
91
|
+
update!(config: config.merge("repo_full_name" => canonical_full_name))
|
|
92
|
+
self
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
61
96
|
def attached_message
|
|
62
97
|
Collavre::Channel::InjectedMessage.new(
|
|
63
98
|
speaker: channel_bot_user,
|
|
@@ -67,6 +102,31 @@ module CollavreGithub
|
|
|
67
102
|
)
|
|
68
103
|
end
|
|
69
104
|
|
|
105
|
+
# Shown when GitHub's delivery could not be authenticated. Renders this
|
|
106
|
+
# channel's OWN stored repo name, never the rejected payload's — the
|
|
107
|
+
# payload is unverified by definition at the point this is built.
|
|
108
|
+
def auth_failure_message
|
|
109
|
+
Collavre::Channel::InjectedMessage.new(
|
|
110
|
+
speaker: channel_bot_user,
|
|
111
|
+
message: t("auth_failure_message", repo: repo_full_name, label: label, url: pr_url),
|
|
112
|
+
label: label,
|
|
113
|
+
link: pr_url
|
|
114
|
+
)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Closing message for `state` (merged / closed_without_merge). Public so
|
|
118
|
+
# the out-of-band corrector (PrChannelStateUpdater) posts the exact same
|
|
119
|
+
# text the webhook would have, instead of maintaining a second copy.
|
|
120
|
+
def closed_message(state = pr_state)
|
|
121
|
+
verb = state.to_s == "merged" ? t("state_merged") : t("state_closed")
|
|
122
|
+
Collavre::Channel::InjectedMessage.new(
|
|
123
|
+
speaker: channel_bot_user,
|
|
124
|
+
message: t("closed_message", label: label, verb: verb),
|
|
125
|
+
label: label,
|
|
126
|
+
link: pr_url
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
70
130
|
def reopened_message
|
|
71
131
|
Collavre::Channel::InjectedMessage.new(
|
|
72
132
|
speaker: channel_bot_user,
|
|
@@ -91,11 +151,21 @@ module CollavreGithub
|
|
|
91
151
|
|
|
92
152
|
private
|
|
93
153
|
|
|
154
|
+
def preferred_over?(other)
|
|
155
|
+
(lifecycle_priority(self) <=> lifecycle_priority(other)) == 1
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def lifecycle_priority(channel)
|
|
159
|
+
[
|
|
160
|
+
channel.active? ? 1 : 0,
|
|
161
|
+
channel.dismissed? ? 0 : 1
|
|
162
|
+
]
|
|
163
|
+
end
|
|
164
|
+
|
|
94
165
|
def handle_pull_request(payload)
|
|
95
166
|
return nil unless payload["action"] == "closed"
|
|
96
167
|
pr = payload["pull_request"]
|
|
97
168
|
new_state = pr["merged"] ? "merged" : "closed_without_merge"
|
|
98
|
-
verb = pr["merged"] ? t("state_merged") : t("state_closed")
|
|
99
169
|
|
|
100
170
|
# pr_state is updated atomically with the closing comment: the dispatch
|
|
101
171
|
# path wraps both `handle` and `inject_into_topic!` in a single with_lock
|
|
@@ -105,12 +175,7 @@ module CollavreGithub
|
|
|
105
175
|
self.pr_state = new_state
|
|
106
176
|
save!
|
|
107
177
|
|
|
108
|
-
|
|
109
|
-
speaker: channel_bot_user,
|
|
110
|
-
message: t("closed_message", label: label, verb: verb),
|
|
111
|
-
label: label,
|
|
112
|
-
link: pr_url
|
|
113
|
-
)
|
|
178
|
+
closed_message(new_state)
|
|
114
179
|
# Detach is performed by the webhook controller AFTER injecting this
|
|
115
180
|
# message, so the chip stays visible until the closing comment lands.
|
|
116
181
|
end
|
|
@@ -21,6 +21,67 @@ module CollavreGithub
|
|
|
21
21
|
|
|
22
22
|
scope :markdown_sync, -> { where(markdown_sync_enabled: true) }
|
|
23
23
|
|
|
24
|
+
# Every link for one GitHub repository, matched by its stable id plus
|
|
25
|
+
# unbackfilled name-only siblings.
|
|
26
|
+
#
|
|
27
|
+
# `repository_full_name` is the only key webhook routing used to have, and
|
|
28
|
+
# it is not stable: renaming a repository on GitHub changes `full_name` in
|
|
29
|
+
# every subsequent payload with no local event to notice it. The lookup
|
|
30
|
+
# then found nothing, the controller fell through to a fallback secret that
|
|
31
|
+
# production does not configure, and every delivery was answered 401 before
|
|
32
|
+
# dispatch — silently, since the deliveries ledger is only written after
|
|
33
|
+
# signature verification. `repository.id` does survive a rename, which is
|
|
34
|
+
# why it is matched first-class here.
|
|
35
|
+
#
|
|
36
|
+
# When an id is present, name matching is restricted to NULL-id rows. A
|
|
37
|
+
# repository linked to two creatives can be half-backfilled (one row
|
|
38
|
+
# stamped, its sibling still nil), so those legacy siblings must remain in
|
|
39
|
+
# the fan-out. Rows carrying another id are authoritative links to another
|
|
40
|
+
# repository, even when GitHub has reused their stored name.
|
|
41
|
+
def self.for_repository(id:, full_name:)
|
|
42
|
+
name = full_name.to_s.downcase.presence
|
|
43
|
+
if id.present?
|
|
44
|
+
by_id = where(repository_id: id)
|
|
45
|
+
return by_id unless name
|
|
46
|
+
|
|
47
|
+
unbackfilled_by_name = where(repository_id: nil)
|
|
48
|
+
.where("LOWER(repository_full_name) = ?", name)
|
|
49
|
+
return by_id.or(unbackfilled_by_name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
name ? where("LOWER(repository_full_name) = ?", name) : none
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A parent link normally applies to every descendant, but a descendant may
|
|
56
|
+
# carry its own link for another repository that reused the same name. An
|
|
57
|
+
# explicit different id proves the conflict; a NULL id is unverified and
|
|
58
|
+
# must fail closed. In either case, the descendant's channels must not be
|
|
59
|
+
# renamed through the parent.
|
|
60
|
+
def self.conflicting_repository_in_scope?(creative:, full_name:, repository_id:)
|
|
61
|
+
return false if creative.blank? || full_name.blank? || repository_id.blank?
|
|
62
|
+
|
|
63
|
+
creative_ids = [ creative.id ] + creative.ancestors.pluck(:id)
|
|
64
|
+
where(creative_id: creative_ids)
|
|
65
|
+
.where("LOWER(repository_full_name) = ?", full_name.to_s.downcase)
|
|
66
|
+
.where("repository_id IS NULL OR repository_id <> ?", repository_id)
|
|
67
|
+
.exists?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Link mutations affect the link's whole descendant subtree, while an
|
|
71
|
+
# ancestor link is inherited into the same scope. Before assigning a
|
|
72
|
+
# canonical name, reject any NULL/different-id row in either direction so
|
|
73
|
+
# case variants cannot leave both repositories permanently undispatchable.
|
|
74
|
+
def self.identity_conflict_in_link_scope(creative:, full_name:, repository_id:, excluding_id: nil)
|
|
75
|
+
return if creative.blank? || full_name.blank? || repository_id.blank?
|
|
76
|
+
|
|
77
|
+
creative_ids = creative.subtree_ids + creative.ancestors.pluck(:id)
|
|
78
|
+
candidates = where(creative_id: creative_ids)
|
|
79
|
+
.where("LOWER(repository_full_name) = ?", full_name.to_s.downcase)
|
|
80
|
+
.where("repository_id IS NULL OR repository_id <> ?", repository_id)
|
|
81
|
+
candidates = candidates.where.not(id: excluding_id) if excluding_id
|
|
82
|
+
candidates.order(:id).first
|
|
83
|
+
end
|
|
84
|
+
|
|
24
85
|
def markdown_sync_branch
|
|
25
86
|
sync_branch.presence || "main"
|
|
26
87
|
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
module CollavreGithub
|
|
2
|
+
# Idempotency ledger for inbound GitHub webhook deliveries, keyed by the
|
|
3
|
+
# `X-GitHub-Delivery` GUID.
|
|
4
|
+
#
|
|
5
|
+
# Two independent duplicate sources exist and both reuse the same GUID:
|
|
6
|
+
# 1. Multiple hooks on one repo (e.g. one per deployed instance sharing this
|
|
7
|
+
# database) — GitHub fans the same delivery out to every hook URL. This
|
|
8
|
+
# is what makes two hooks a bandwidth cost rather than the duplicate
|
|
9
|
+
# processing this ledger exists to stop, so it is load-bearing. The
|
|
10
|
+
# reference docs do state it, in one word that is easy to read past:
|
|
11
|
+
# `X-GitHub-Delivery` identifies "the event", not the delivery, so every
|
|
12
|
+
# destination subscribed to one event receives the same value and
|
|
13
|
+
# `X-GitHub-Hook-ID` is what differs. That wording read "the delivery"
|
|
14
|
+
# until github/docs#33184 corrected it (2024-05-27), after a GitHub
|
|
15
|
+
# engineer confirmed on github/docs#32822 that one event fans out to
|
|
16
|
+
# every listener under a single GUID.
|
|
17
|
+
# 2. Redelivery of a failed delivery. Never automatic: GitHub does not
|
|
18
|
+
# retry a 4xx, a 5xx or a >10s timeout, it only records the failure.
|
|
19
|
+
# Recovery is an operator pressing Redeliver or a script polling the
|
|
20
|
+
# deliveries API, within GitHub's 3-day window — and either way the
|
|
21
|
+
# original GUID comes back, which is what brings it here.
|
|
22
|
+
# A single UNIQUE claim on the GUID collapses both.
|
|
23
|
+
class WebhookDelivery < ApplicationRecord
|
|
24
|
+
self.table_name = "github_webhook_deliveries"
|
|
25
|
+
|
|
26
|
+
# Rows only exist to answer "have I seen this GUID?" — an updated_at column
|
|
27
|
+
# would never be written.
|
|
28
|
+
self.record_timestamps = false
|
|
29
|
+
|
|
30
|
+
# Sized against GitHub's redelivery window, which is 3 days: a delivery
|
|
31
|
+
# older than that can no longer be redelivered at all, so its row can never
|
|
32
|
+
# be consulted again. 7 days keeps better than 2x margin. Pruning EARLIER
|
|
33
|
+
# than the window would be the dangerous direction — the GUID would be
|
|
34
|
+
# claimed as new and every side effect would run a second time.
|
|
35
|
+
# https://docs.github.com/en/webhooks/testing-and-troubleshooting-webhooks/redelivering-webhooks
|
|
36
|
+
RETENTION = 7.days
|
|
37
|
+
|
|
38
|
+
# How long a claim may sit unprocessed before another delivery of the same
|
|
39
|
+
# GUID is allowed to take it over. `WebhooksController` releases its claim
|
|
40
|
+
# on any exception the process survives, so this window only covers a
|
|
41
|
+
# request that died without running its rescue — SIGKILL, an OOM, a worker
|
|
42
|
+
# timeout. Without the takeover such a GUID would answer 200 forever and
|
|
43
|
+
# the operator's Redeliver button would silently do nothing.
|
|
44
|
+
STALE_CLAIM_AFTER = 5.minutes
|
|
45
|
+
|
|
46
|
+
validates :delivery_guid, presence: true
|
|
47
|
+
|
|
48
|
+
# Claims the delivery for this process. Returns an ownership token when the
|
|
49
|
+
# caller owns the delivery and should process it, nil when another request
|
|
50
|
+
# (another hook, or a redelivery) already claimed it.
|
|
51
|
+
#
|
|
52
|
+
# The token, not the GUID, is what `release` and `mark_processed!` act on.
|
|
53
|
+
# Ownership is not permanent — a run that outlives STALE_CLAIM_AFTER has its
|
|
54
|
+
# claim taken over — so a caller that acted on the GUID alone would reach
|
|
55
|
+
# past its own claim and disturb the run that replaced it.
|
|
56
|
+
#
|
|
57
|
+
# A blank GUID means the request did not come from GitHub's delivery
|
|
58
|
+
# pipeline (hand-crafted request, legacy test client). There is nothing to
|
|
59
|
+
# deduplicate on, so it is allowed through rather than silently dropped —
|
|
60
|
+
# dropping would turn every unlabelled delivery into a no-op. UNTRACKED
|
|
61
|
+
# keeps the caller's contract uniform: it is truthy, and the GUID-blank
|
|
62
|
+
# guard in `release`/`mark_processed!` makes both a no-op for it.
|
|
63
|
+
UNTRACKED = "untracked".freeze
|
|
64
|
+
|
|
65
|
+
def self.claim(delivery_guid, event: nil)
|
|
66
|
+
return UNTRACKED if delivery_guid.blank?
|
|
67
|
+
|
|
68
|
+
insert_claim(delivery_guid, event)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Resolves a collision on the UNIQUE index. Three things can be true of the
|
|
72
|
+
# row that beat us to it:
|
|
73
|
+
#
|
|
74
|
+
# - it is stale, and `reclaim_stale` takes it over;
|
|
75
|
+
# - it is live, so this delivery genuinely is a duplicate;
|
|
76
|
+
# - it is GONE, because the request holding it failed and called
|
|
77
|
+
# `release` between our INSERT and this lookup. Reporting a duplicate
|
|
78
|
+
# then would lose the event outright — that request has already given
|
|
79
|
+
# up, so nobody would process it — which is the very outcome the
|
|
80
|
+
# ledger exists to prevent. The insert is retried instead.
|
|
81
|
+
#
|
|
82
|
+
# One retry suffices: it only has to cover the window opened by that single
|
|
83
|
+
# release, and bounding it keeps a pathological loop of releases from
|
|
84
|
+
# spinning here forever.
|
|
85
|
+
def self.insert_claim(delivery_guid, event, retried: false)
|
|
86
|
+
token = SecureRandom.uuid
|
|
87
|
+
create!(
|
|
88
|
+
delivery_guid: delivery_guid,
|
|
89
|
+
event: event,
|
|
90
|
+
claim_token: token,
|
|
91
|
+
created_at: Time.current
|
|
92
|
+
)
|
|
93
|
+
token
|
|
94
|
+
rescue ActiveRecord::RecordNotUnique
|
|
95
|
+
reclaimed = reclaim_stale(delivery_guid, event: event)
|
|
96
|
+
return reclaimed if reclaimed
|
|
97
|
+
return nil if retried || exists?(delivery_guid: delivery_guid)
|
|
98
|
+
|
|
99
|
+
insert_claim(delivery_guid, event, retried: true)
|
|
100
|
+
end
|
|
101
|
+
private_class_method :insert_claim
|
|
102
|
+
|
|
103
|
+
# Takes over an abandoned claim. A row on its own does not prove the
|
|
104
|
+
# delivery was handled — only `processed_at` does — so an unprocessed row
|
|
105
|
+
# older than STALE_CLAIM_AFTER is treated as owner-less.
|
|
106
|
+
#
|
|
107
|
+
# Concurrent takeovers resolve to one winner without extra locking: the
|
|
108
|
+
# UPDATE takes a row lock, and the loser re-evaluates its WHERE against the
|
|
109
|
+
# committed row, whose `created_at` is no longer stale, so it matches
|
|
110
|
+
# nothing and reports the delivery as a duplicate.
|
|
111
|
+
#
|
|
112
|
+
# The takeover issues a FRESH token, which is what strips ownership from the
|
|
113
|
+
# run being replaced: its own `release`/`mark_processed!` no longer match.
|
|
114
|
+
def self.reclaim_stale(delivery_guid, event: nil)
|
|
115
|
+
token = SecureRandom.uuid
|
|
116
|
+
updated = where(delivery_guid: delivery_guid, processed_at: nil)
|
|
117
|
+
.where(created_at: ...STALE_CLAIM_AFTER.ago)
|
|
118
|
+
.update_all(created_at: Time.current, event: event, claim_token: token)
|
|
119
|
+
|
|
120
|
+
token if updated.positive?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Marks a claim as genuinely handled. Only after this does a redelivery of
|
|
124
|
+
# the same GUID get dismissed as a duplicate.
|
|
125
|
+
#
|
|
126
|
+
# Scoped to the token: if this run was superseded while it worked, the row
|
|
127
|
+
# now belongs to its replacement and must not be stamped processed on its
|
|
128
|
+
# behalf — the replacement is still running and has yet to earn that.
|
|
129
|
+
def self.mark_processed!(delivery_guid, token)
|
|
130
|
+
return if delivery_guid.blank? || token.blank?
|
|
131
|
+
|
|
132
|
+
where(delivery_guid: delivery_guid, claim_token: token)
|
|
133
|
+
.update_all(processed_at: Time.current)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Drops a claim whose run failed so the redelivery is processed instead of
|
|
137
|
+
# dismissed.
|
|
138
|
+
#
|
|
139
|
+
# Scoped to the token as well as to unprocessed rows. Unprocessed alone was
|
|
140
|
+
# not enough: a run slower than STALE_CLAIM_AFTER has already lost the row
|
|
141
|
+
# to a replacement, and deleting on the GUID would have taken the
|
|
142
|
+
# replacement's live claim with it — freeing the GUID for a third request
|
|
143
|
+
# to claim while the replacement was still running, and leaving the
|
|
144
|
+
# replacement's own `mark_processed!` with no row to stamp, so every later
|
|
145
|
+
# redelivery would repeat the event.
|
|
146
|
+
def self.release(delivery_guid, token)
|
|
147
|
+
return if delivery_guid.blank? || token.blank?
|
|
148
|
+
|
|
149
|
+
where(delivery_guid: delivery_guid, claim_token: token, processed_at: nil).delete_all
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Called from the recurring prune job. Past RETENTION the delivery has aged
|
|
153
|
+
# out of GitHub's 3-day Redeliver window, so nothing can reuse the GUID and
|
|
154
|
+
# the row only grows the table.
|
|
155
|
+
def self.prune!(older_than: RETENTION.ago)
|
|
156
|
+
where(created_at: ...older_than).delete_all
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
module CollavreGithub
|
|
2
2
|
class Client
|
|
3
3
|
MOCK_SERVER_DEFAULT = "http://localhost:4567"
|
|
4
|
+
RepositoryIdentity = Data.define(:id, :full_name)
|
|
4
5
|
|
|
5
6
|
def initialize(account)
|
|
6
7
|
options = { access_token: account.token }
|
|
@@ -71,6 +72,19 @@ module CollavreGithub
|
|
|
71
72
|
[]
|
|
72
73
|
end
|
|
73
74
|
|
|
75
|
+
# The same listing, with the failure reported instead of swallowed. A
|
|
76
|
+
# caller asking whether a specific hook still exists cannot use the form
|
|
77
|
+
# above: an empty array would mean "GitHub says it is gone" and "GitHub did
|
|
78
|
+
# not answer" alike, and those two demand opposite actions.
|
|
79
|
+
def repository_hooks!(repo_full_name)
|
|
80
|
+
client.hooks(repo_full_name)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def repository_identity(repo_full_name)
|
|
84
|
+
repository = client.repository(repo_full_name)
|
|
85
|
+
RepositoryIdentity.new(id: repository.id, full_name: repository.full_name)
|
|
86
|
+
end
|
|
87
|
+
|
|
74
88
|
def create_repository_webhook(repo_full_name, url:, secret:, events:, content_type: "json")
|
|
75
89
|
client.create_hook(
|
|
76
90
|
repo_full_name,
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreGithub
|
|
4
|
+
# Out-of-band PR state correction: applies the same end-state the webhook
|
|
5
|
+
# would have produced when a `pull_request` event was never delivered (hook
|
|
6
|
+
# added after the merge, delivery failure, repo renamed mid-flight).
|
|
7
|
+
#
|
|
8
|
+
# The webhook path does NOT route through here on purpose. There, `handle`
|
|
9
|
+
# only *returns* the closing message and WebhooksController injects it and
|
|
10
|
+
# detaches, because the controller owns the per-channel lock that also
|
|
11
|
+
# de-duplicates GitHub retries. This class owns the whole transition instead,
|
|
12
|
+
# so it takes the lock itself. Both paths share the message builders on
|
|
13
|
+
# GithubPrChannel, which is the part that would otherwise drift.
|
|
14
|
+
class PrChannelStateUpdater
|
|
15
|
+
# status ∈ :updated / :noop
|
|
16
|
+
Result = Struct.new(:status, :channel, :previous_state, keyword_init: true)
|
|
17
|
+
|
|
18
|
+
def self.call(channel:, state:)
|
|
19
|
+
new(channel: channel, state: state).call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(channel:, state:)
|
|
23
|
+
@channel = channel
|
|
24
|
+
@state = state.to_s
|
|
25
|
+
unless GithubPrChannel::PR_STATES.include?(@state)
|
|
26
|
+
raise ArgumentError, "Invalid pr_state: #{state.inspect}"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call
|
|
31
|
+
channel.with_lock do
|
|
32
|
+
previous_state = channel.pr_state
|
|
33
|
+
next Result.new(status: :noop, channel: channel, previous_state: previous_state) if settled?
|
|
34
|
+
|
|
35
|
+
if state == "open"
|
|
36
|
+
reopen!
|
|
37
|
+
else
|
|
38
|
+
close!
|
|
39
|
+
end
|
|
40
|
+
Result.new(status: :updated, channel: channel, previous_state: previous_state)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
attr_reader :channel, :state
|
|
47
|
+
|
|
48
|
+
# A repeat call must not inject a second timeline message. "Same state" is
|
|
49
|
+
# not enough on its own: a channel can read "open" while sitting detached
|
|
50
|
+
# (dismissed by the user, or reopened only in GitHub), and that still needs
|
|
51
|
+
# the reactivation half of the transition applied.
|
|
52
|
+
def settled?
|
|
53
|
+
return false unless channel.pr_state == state
|
|
54
|
+
|
|
55
|
+
if state == "open"
|
|
56
|
+
channel.active? && !channel.dismissed?
|
|
57
|
+
else
|
|
58
|
+
channel.detached?
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Mirrors WebhooksController's reopen handling: state back to active and
|
|
63
|
+
# the chip un-dismissed, so the channel resurfaces under `not_dismissed`
|
|
64
|
+
# and resumes receiving events.
|
|
65
|
+
def reopen!
|
|
66
|
+
channel.state = :active
|
|
67
|
+
channel.dismissed_at = nil
|
|
68
|
+
channel.pr_state = "open"
|
|
69
|
+
channel.save!
|
|
70
|
+
channel.inject_into_topic!(channel.reopened_message)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Mirrors the closed-event path: set the badge, post the closing message,
|
|
74
|
+
# then detach — in that order, so the chip stays visible (now merged /
|
|
75
|
+
# closed) until the user dismisses it.
|
|
76
|
+
def close!
|
|
77
|
+
channel.pr_state = state
|
|
78
|
+
channel.save!
|
|
79
|
+
channel.inject_into_topic!(channel.closed_message(state))
|
|
80
|
+
channel.detach! if channel.active?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
module CollavreGithub
|
|
2
|
+
# Persists a repository identity established through trusted evidence and
|
|
3
|
+
# keeps attached channel routing keys in sync with the canonical GitHub name.
|
|
4
|
+
#
|
|
5
|
+
# Stable repository ids are authoritative. A NULL-id anchor is accepted only
|
|
6
|
+
# by the explicit operator reattachment workflow; hook registrations and HMAC
|
|
7
|
+
# secrets are not row identity because older provisioners propagated both.
|
|
8
|
+
class RepositoryIdentitySynchronizer
|
|
9
|
+
def self.call(
|
|
10
|
+
anchor:,
|
|
11
|
+
repository_id:,
|
|
12
|
+
full_name:,
|
|
13
|
+
trusted_secret: nil,
|
|
14
|
+
allow_anchor_backfill: false
|
|
15
|
+
)
|
|
16
|
+
return [] if anchor.blank? || repository_id.blank? || full_name.blank?
|
|
17
|
+
|
|
18
|
+
RepositoryProvisioningLock.with_repository_name_lock(full_name) do
|
|
19
|
+
new(
|
|
20
|
+
anchor: anchor,
|
|
21
|
+
repository_id: repository_id,
|
|
22
|
+
full_name: full_name,
|
|
23
|
+
trusted_secret: trusted_secret,
|
|
24
|
+
allow_anchor_backfill: allow_anchor_backfill
|
|
25
|
+
).call
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(
|
|
30
|
+
anchor:,
|
|
31
|
+
repository_id:,
|
|
32
|
+
full_name:,
|
|
33
|
+
trusted_secret:,
|
|
34
|
+
allow_anchor_backfill:
|
|
35
|
+
)
|
|
36
|
+
@anchor = anchor
|
|
37
|
+
@repository_id = repository_id
|
|
38
|
+
@full_name = full_name.to_s
|
|
39
|
+
@trusted_secret = trusted_secret
|
|
40
|
+
@allow_anchor_backfill = allow_anchor_backfill
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call
|
|
44
|
+
return [] if anchor.blank? || repository_id.blank? || full_name.blank?
|
|
45
|
+
|
|
46
|
+
synchronized = []
|
|
47
|
+
old_names_by_creative = Hash.new { |hash, key| hash[key] = [] }
|
|
48
|
+
|
|
49
|
+
RepositoryLink.transaction do
|
|
50
|
+
candidate_links.each do |link|
|
|
51
|
+
old_name = link.repository_full_name
|
|
52
|
+
survivor = synchronize_link(link)
|
|
53
|
+
next unless survivor
|
|
54
|
+
|
|
55
|
+
synchronized << survivor
|
|
56
|
+
if old_name.to_s == full_name
|
|
57
|
+
next
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
old_names_by_creative[old_name.to_s.downcase] << survivor.creative_id
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
old_names_by_creative.each do |old_name, creative_ids|
|
|
64
|
+
rename_channels(old_name, creative_ids.uniq)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
synchronized.uniq(&:id)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
attr_reader :anchor,
|
|
74
|
+
:repository_id,
|
|
75
|
+
:full_name,
|
|
76
|
+
:trusted_secret,
|
|
77
|
+
:allow_anchor_backfill
|
|
78
|
+
|
|
79
|
+
def candidate_links
|
|
80
|
+
links = RepositoryLink.where(repository_id: repository_id).to_a
|
|
81
|
+
links << anchor if allow_anchor_backfill || anchor.repository_id.to_s == repository_id.to_s
|
|
82
|
+
links.uniq(&:id)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def synchronize_link(link)
|
|
86
|
+
if link.repository_full_name.to_s == full_name &&
|
|
87
|
+
link.repository_id.to_s == repository_id.to_s
|
|
88
|
+
return link
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
conflict = RepositoryLink.identity_conflict_in_link_scope(
|
|
92
|
+
creative: link.creative,
|
|
93
|
+
full_name: full_name,
|
|
94
|
+
repository_id: repository_id,
|
|
95
|
+
excluding_id: link.id
|
|
96
|
+
)
|
|
97
|
+
return if conflict
|
|
98
|
+
|
|
99
|
+
survivor = RepositoryLink
|
|
100
|
+
.where(creative_id: link.creative_id)
|
|
101
|
+
.where("LOWER(repository_full_name) = ?", full_name.downcase)
|
|
102
|
+
.where.not(id: link.id)
|
|
103
|
+
.first
|
|
104
|
+
|
|
105
|
+
if survivor
|
|
106
|
+
return unless survivor.repository_id.to_s == repository_id.to_s
|
|
107
|
+
|
|
108
|
+
merge_links!(obsolete: link, survivor: survivor)
|
|
109
|
+
return survivor
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
link.update_columns(repository_id: repository_id, repository_full_name: full_name)
|
|
113
|
+
link
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def merge_links!(obsolete:, survivor:)
|
|
117
|
+
survivor.update!(
|
|
118
|
+
webhook_secret: trusted_secret.presence || survivor.webhook_secret,
|
|
119
|
+
webhook_hook_id: survivor.webhook_hook_id || obsolete.webhook_hook_id,
|
|
120
|
+
markdown_sync_enabled: survivor.markdown_sync_enabled? || obsolete.markdown_sync_enabled?,
|
|
121
|
+
markdown_root_creative_id: survivor.markdown_root_creative_id || obsolete.markdown_root_creative_id,
|
|
122
|
+
sync_branch: survivor.sync_branch.presence || obsolete.sync_branch,
|
|
123
|
+
last_synced_at: [ survivor.last_synced_at, obsolete.last_synced_at ].compact.max
|
|
124
|
+
)
|
|
125
|
+
obsolete.destroy!
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def rename_channels(old_name, creative_ids)
|
|
129
|
+
GithubPrChannel.where("LOWER(repo_full_name) = ?", old_name).find_each do |channel|
|
|
130
|
+
creative = channel.topic&.creative
|
|
131
|
+
next unless creative
|
|
132
|
+
|
|
133
|
+
candidate_ids = [ creative.id ] + creative.ancestors.pluck(:id)
|
|
134
|
+
next if (creative_ids & candidate_ids).empty?
|
|
135
|
+
next if RepositoryLink.conflicting_repository_in_scope?(
|
|
136
|
+
creative: creative,
|
|
137
|
+
full_name: old_name,
|
|
138
|
+
repository_id: repository_id
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
channel.synchronize_repository_name!(full_name)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|