collavre_github 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d8f3ffe59f1a7e85a1a42fd49cd5fa3ee1ad40667996a69e405fef0bb8415318
4
- data.tar.gz: 5a9c05bf70a341ca56253bfa2d6a2849acc335038e42c1b9919747be2d564f7f
3
+ metadata.gz: 10db15f8f1d4877e2b2b8c3c52b6a7bc6b906f742b6ef22d72c5edcdee89ee22
4
+ data.tar.gz: cc55e2500dafc1b6d19e59f6cb003b966de5ec9394009def30525c0a5b3c00b3
5
5
  SHA512:
6
- metadata.gz: ee53fe7588353302c64f84decc488e62b1d8f9fb9d65f8332439d0bd8d2f353fc8777ff59724c9c9d82390fd5094ab513f796ab5b1666fd456e382fd0d13099b
7
- data.tar.gz: aa439ab76e282b9841abcaea1a84ad0e3ccfaf06214cb8380af7e24661ea93848de8197fd8f69b4108b5a4b609ed2cc8caaae69cc5c59e48f833328a6829b2e6
6
+ metadata.gz: ce8c29467f23b907a04ef532588d059ca1e7fa2f838e600fd730ba04becc5aba205f2669828a3ac520cd7ace29f29bfb0cc9eec10da776aa5f55693c42860143
7
+ data.tar.gz: 456701d9b38e6b8498064cede91e6931d2abea3c1d9671d73e6a370aa8e251d207e69b0d5000aac29dd5db597fbb4d1cb28b556bbed02b117dd813ef54f75c6d
@@ -17,10 +17,13 @@ module CollavreGithub
17
17
  # Process all links for this repo (same repo can be linked to multiple creatives)
18
18
  all_links = all_repository_links_for(payload)
19
19
  all_links.each do |link|
20
- create_system_comment_for(link, event, payload) if link.creative
20
+ create_system_comment_for(link, event, payload) if link.creative && !channel_only_event?(event)
21
21
  trigger_markdown_sync_for(link, event, payload) if link.markdown_sync_enabled?
22
22
  end
23
23
 
24
+ maybe_auto_attach_channel(event, payload)
25
+ dispatch_to_channels(event, payload)
26
+
24
27
  head :ok
25
28
  rescue JSON::ParserError
26
29
  head :bad_request
@@ -28,6 +31,194 @@ module CollavreGithub
28
31
 
29
32
  private
30
33
 
34
+ # `WebhookProvisioner` auto-subscribes every repo webhook to the events
35
+ # GithubPrChannel needs (`issue_comment`, `pull_request_review`,
36
+ # `pull_request_review_comment`). Those events must only reach attached
37
+ # PR channels — letting them through the creative feed would spam every
38
+ # linked creative with system comments from issues/PRs that nobody asked
39
+ # to monitor. `push` and `pull_request` continue to flow into the feed.
40
+ def channel_only_event?(event)
41
+ CollavreGithub::WebhookProvisioner::CHANNEL_EVENTS.include?(event)
42
+ end
43
+
44
+ def maybe_auto_attach_channel(event, payload)
45
+ return unless event == "pull_request" && %w[opened reopened].include?(payload["action"])
46
+ # GitHub repo identifiers are case-insensitive; normalize so stored
47
+ # channels (also lowercased) match incoming dispatch payloads regardless
48
+ # of how the repo casing arrives from clients.
49
+ repo = payload.dig("repository", "full_name")&.downcase
50
+ pr_number = payload.dig("pull_request", "number")
51
+ return unless repo && pr_number
52
+
53
+ # `reopened` must resurrect ANY existing channel for this (repo, pr) — not
54
+ # just channels whose PR body still contains a topic link. Manually attached
55
+ # channels (via `pr_monitor`) and PRs whose body link was later removed both
56
+ # have a valid channel but no link; without this branch, the body-link path
57
+ # below would short-circuit and dispatch_to_channels (.active scope) would
58
+ # skip the dismissed/detached row, leaving the chip hidden and the PR
59
+ # unmonitored for the rest of its reopened life.
60
+ reactivate_existing_channels_on_reopen(repo, pr_number) if payload["action"] == "reopened"
61
+
62
+ # Body-link path: only used to CREATE a new channel. Existing-channel paths
63
+ # are intentionally handled above (reopened) or as a strict no-op (opened
64
+ # redelivery — must not undo a user's X dismissal).
65
+ body = payload.dig("pull_request", "body")
66
+ topic_id = CollavreGithub::PrTopicLinkParser.call(body)
67
+ return unless topic_id
68
+
69
+ topic = Collavre::Topic.find_by(id: topic_id)
70
+ return unless topic
71
+
72
+ # Security: the PR description is attacker-controlled. Anyone able to open
73
+ # a PR on this repo could otherwise drop a link to an unrelated tenant's
74
+ # topic and have subsequent PR comments injected there. Only auto-attach
75
+ # when the topic's creative — or any of its ancestors — is linked to this
76
+ # repo (RepositoryLink applies to the whole subtree).
77
+ linked_creative_ids = CollavreGithub::RepositoryLink
78
+ .where("LOWER(repository_full_name) = ?", repo).pluck(:creative_id)
79
+ creative = topic.creative
80
+ return unless creative
81
+ candidate_ids = [ creative.id ] + creative.ancestors.pluck(:id)
82
+ return if (linked_creative_ids & candidate_ids).empty?
83
+
84
+ existing = GithubPrChannel.where(topic_id: topic.id).find do |c|
85
+ c.repo_full_name.to_s.downcase == repo && c.pr_number == pr_number
86
+ end
87
+ # Existing channel: strict no-op. `reopened` was already handled by the
88
+ # repo+pr scan above; `opened` redelivery must leave dismissed_at intact.
89
+ return existing if existing
90
+
91
+ GithubPrChannel.create!(
92
+ topic_id: topic.id,
93
+ config: { "repo_full_name" => repo, "pr_number" => pr_number, "pr_state" => "open" }
94
+ )
95
+ rescue ActiveRecord::RecordNotUnique
96
+ # concurrent webhook safe
97
+ rescue => e
98
+ Rails.logger.error("[CollavreGithub] auto-attach failed: #{e.class}: #{e.message}")
99
+ end
100
+
101
+ # Resurrect every existing channel for (repo, pr_number) on `pull_request.
102
+ # reopened`, regardless of whether the PR description currently contains a
103
+ # topic link. Mirrors dispatch's scope re-validation so a channel whose
104
+ # creative is no longer linked to this repo is NOT resurrected.
105
+ def reactivate_existing_channels_on_reopen(repo, pr_number)
106
+ linked_creative_ids = CollavreGithub::RepositoryLink
107
+ .where("LOWER(repository_full_name) = ?", repo).pluck(:creative_id)
108
+ return if linked_creative_ids.empty?
109
+
110
+ GithubPrChannel.find_each do |channel|
111
+ next unless channel.repo_full_name.to_s.downcase == repo && channel.pr_number == pr_number
112
+ next unless channel_in_repo_scope?(channel, linked_creative_ids)
113
+
114
+ begin
115
+ # Row-level lock + re-read guards against duplicate reopened announcements
116
+ # when two `pull_request.reopened` deliveries for the same dismissed/
117
+ # detached channel arrive concurrently (GitHub retries on 5xx, or
118
+ # duplicate fan-out). Without it, both requests can read was_inactive=true
119
+ # before either clears dismissed_at, then both inject the reopened
120
+ # message. Mirrors the close-path with_lock in dispatch_to_channels.
121
+ channel.with_lock do
122
+ was_inactive = !channel.active? || !channel.dismissed_at.nil?
123
+ if was_inactive || channel.pr_state != "open"
124
+ channel.state = :active unless channel.active?
125
+ channel.dismissed_at = nil unless channel.dismissed_at.nil?
126
+ channel.pr_state = "open" if channel.pr_state != "open"
127
+ channel.save!
128
+ end
129
+ # When the chip silently reappears after dismiss/detach, inject a
130
+ # one-line announcement so the user can trace the lifecycle —
131
+ # mirrors the attach announcement on first create.
132
+ if was_inactive
133
+ begin
134
+ channel.inject_into_topic!(channel.reopened_message)
135
+ rescue => e
136
+ Rails.logger.error("[CollavreGithub] reopened announce failed: #{e.class}: #{e.message}")
137
+ end
138
+ end
139
+ end
140
+ rescue => e
141
+ # Per-channel isolation: one bad row must not block sibling channels.
142
+ Rails.logger.error(
143
+ "[CollavreGithub] reopen reactivate failed for channel #{channel.id}: #{e.class}: #{e.message}"
144
+ )
145
+ end
146
+ end
147
+ end
148
+
149
+ def dispatch_to_channels(event, payload)
150
+ repo = payload.dig("repository", "full_name")&.downcase
151
+ pr_number = extract_pr_number(event, payload)
152
+ return if repo.blank? || pr_number.nil?
153
+
154
+ # Re-resolve which creatives are linked to this repo on every dispatch.
155
+ # The auto-attach guard validated the link at creation time, but a
156
+ # RepositoryLink can be removed or a topic can be moved to a different
157
+ # creative subtree after attachment. Without re-validating here, an
158
+ # orphaned channel would keep receiving cross-tenant PR events.
159
+ linked_creative_ids = CollavreGithub::RepositoryLink
160
+ .where("LOWER(repository_full_name) = ?", repo).pluck(:creative_id)
161
+
162
+ # Ruby-level filter for DB portability (SQLite dev/test, Postgres prod).
163
+ # Future optimization: switch to a jsonb-portable query once an established
164
+ # pattern exists in this codebase. Compare repo names case-insensitively
165
+ # so legacy mixed-case rows continue to match the canonical lowercase
166
+ # payload value.
167
+ GithubPrChannel.active.find_each do |channel|
168
+ next unless channel.repo_full_name.to_s.downcase == repo && channel.pr_number == pr_number
169
+ next unless channel_in_repo_scope?(channel, linked_creative_ids)
170
+
171
+ begin
172
+ # Row-level lock + re-check guards against duplicate dispatch when the
173
+ # same webhook is redelivered (GitHub retries on 5xx) or two webhooks
174
+ # arrive concurrently. Without it, both processes read state=active
175
+ # and each inject the closing comment. The query-level .active scope
176
+ # alone does not race-protect the inject+detach window.
177
+ channel.with_lock do
178
+ next unless channel.active?
179
+
180
+ injected = channel.handle(event: event, payload: payload)
181
+ next if injected.nil?
182
+
183
+ channel.inject_into_topic!(injected)
184
+ # Detach AFTER injecting the closing message so the chip remains
185
+ # visible (now with merged/closed badge) until dismissed by the user.
186
+ channel.detach! if event == "pull_request" && payload["action"] == "closed"
187
+ end
188
+ rescue => e
189
+ # Isolate per-channel failures so one broken channel does not block
190
+ # sibling channels monitoring the same PR.
191
+ Rails.logger.error(
192
+ "[CollavreGithub] channel #{channel.id} dispatch failed: #{e.class}: #{e.message}"
193
+ )
194
+ end
195
+ end
196
+ end
197
+
198
+ # A channel is in-scope iff its topic's creative — or any ancestor — is
199
+ # still listed in a RepositoryLink for the webhook's repo. Mirrors the
200
+ # auto-attach guard so removing a link severs the dispatch, not just the
201
+ # ability to create new monitors.
202
+ def channel_in_repo_scope?(channel, linked_creative_ids)
203
+ return false if linked_creative_ids.empty?
204
+
205
+ creative = channel.topic&.creative
206
+ return false unless creative
207
+
208
+ candidate_ids = [ creative.id ] + creative.ancestors.pluck(:id)
209
+ (linked_creative_ids & candidate_ids).any?
210
+ end
211
+
212
+ def extract_pr_number(event, payload)
213
+ case event
214
+ when "issue_comment"
215
+ n = payload.dig("issue", "number")
216
+ n if payload.dig("issue", "pull_request")
217
+ when "pull_request_review_comment", "pull_request_review", "pull_request"
218
+ payload.dig("pull_request", "number")
219
+ end
220
+ end
221
+
31
222
  def create_system_comment_for(link, event, payload)
32
223
  creative = link.creative&.effective_origin
33
224
  return unless creative
@@ -220,7 +411,9 @@ module CollavreGithub
220
411
  repo = payload&.dig("repository", "full_name") || payload&.dig(:repository, :full_name)
221
412
  return [ @repository_link ].compact if repo.blank?
222
413
 
223
- CollavreGithub::RepositoryLink.where(repository_full_name: repo).to_a
414
+ CollavreGithub::RepositoryLink
415
+ .where("LOWER(repository_full_name) = ?", repo.downcase)
416
+ .to_a
224
417
  end
225
418
 
226
419
  def find_repository_link(payload)
@@ -241,7 +434,9 @@ module CollavreGithub
241
434
  return
242
435
  end
243
436
 
244
- CollavreGithub::RepositoryLink.find_by(repository_full_name: full_name)
437
+ CollavreGithub::RepositoryLink
438
+ .where("LOWER(repository_full_name) = ?", full_name.downcase)
439
+ .first
245
440
  end
246
441
 
247
442
  def valid_signature?(raw_body)
@@ -275,7 +470,11 @@ module CollavreGithub
275
470
  end
276
471
 
277
472
  def fallback_webhook_secret
278
- ENV["GITHUB_WEBHOOK_SECRET"] || Rails.application.credentials.dig(:github, :webhook_secret)
473
+ resolved =
474
+ if defined?(Collavre::IntegrationSettings::Resolver)
475
+ Collavre::IntegrationSettings::Resolver.get(:github_webhook_secret).presence
476
+ end
477
+ resolved || ENV["GITHUB_WEBHOOK_SECRET"] || Rails.application.credentials.dig(:github, :webhook_secret)
279
478
  end
280
479
 
281
480
  def parse_payload(raw_body)