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 +4 -4
- data/app/controllers/collavre_github/webhooks_controller.rb +203 -4
- data/app/javascript/collavre_github.js +706 -0
- data/app/models/collavre_github/github_pr_channel.rb +207 -0
- data/app/services/collavre_github/client.rb +19 -5
- data/app/services/collavre_github/pr_topic_link_parser.rb +11 -0
- data/app/services/collavre_github/tools/permission_denied_error.rb +10 -0
- data/app/services/collavre_github/tools/pr_monitor_service.rb +152 -0
- data/app/services/collavre_github/webhook_provisioner.rb +20 -7
- data/config/initializers/integration_settings.rb +11 -0
- data/config/locales/en.yml +18 -0
- data/config/locales/ko.yml +18 -0
- data/db/seeds.rb +1 -0
- data/lib/collavre_github/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 10db15f8f1d4877e2b2b8c3c52b6a7bc6b906f742b6ef22d72c5edcdee89ee22
|
|
4
|
+
data.tar.gz: cc55e2500dafc1b6d19e59f6cb003b966de5ec9394009def30525c0a5b3c00b3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
-
|
|
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)
|