collavre_github 0.6.3 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/collavre_github/creatives/integrations_controller.rb +253 -36
  3. data/app/controllers/collavre_github/webhooks_controller.rb +562 -37
  4. data/app/jobs/collavre_github/webhook_delivery_prune_job.rb +14 -0
  5. data/app/models/collavre_github/github_pr_channel.rb +72 -7
  6. data/app/models/collavre_github/repository_link.rb +61 -0
  7. data/app/models/collavre_github/webhook_delivery.rb +159 -0
  8. data/app/services/collavre_github/client.rb +14 -0
  9. data/app/services/collavre_github/pr_channel_state_updater.rb +83 -0
  10. data/app/services/collavre_github/repository_identity_synchronizer.rb +145 -0
  11. data/app/services/collavre_github/repository_provisioning_lock.rb +95 -0
  12. data/app/services/collavre_github/tools/concerns/pr_channel_locator.rb +50 -0
  13. data/app/services/collavre_github/tools/pr_monitor_service.rb +80 -61
  14. data/app/services/collavre_github/tools/pr_state_set_service.rb +170 -0
  15. data/app/services/collavre_github/webhook_provisioner.rb +600 -41
  16. data/app/services/collavre_github/webhook_reprovisioner.rb +118 -0
  17. data/config/locales/en.yml +1 -0
  18. data/config/locales/ko.yml +1 -0
  19. data/config/routes.rb +13 -3
  20. data/db/migrate/20260727000001_add_webhook_hook_id_to_github_repository_links.rb +17 -0
  21. data/db/migrate/20260727000002_create_github_webhook_deliveries.rb +30 -0
  22. data/db/migrate/20260729000000_add_repository_id_index_to_github_repository_links.rb +13 -0
  23. data/db/seeds.rb +1 -0
  24. data/lib/collavre_github/version.rb +1 -1
  25. metadata +12 -1
@@ -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
- Collavre::Channel::InjectedMessage.new(
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