collavre_github 0.5.1 → 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: 6fb4ed5d918d784abb2dbfcaf6618391b4e1f888ccd166844db4bd823dce2e08
4
- data.tar.gz: 9d569c3485a15ae9a282c2857bf8e30a3928e30f50fe9efa6e1ab13f12d2fe7d
3
+ metadata.gz: 10db15f8f1d4877e2b2b8c3c52b6a7bc6b906f742b6ef22d72c5edcdee89ee22
4
+ data.tar.gz: cc55e2500dafc1b6d19e59f6cb003b966de5ec9394009def30525c0a5b3c00b3
5
5
  SHA512:
6
- metadata.gz: ed515fb1cd7ea7481ec30269b871b792a74c8a30d780abe4fb5473bf478111937f8748a60e4a432928aa7dbe75f8bef8fc8f1f91bacbd809b8b6367012b91f4a
7
- data.tar.gz: 4ffb6a588103ddf1865539e93bff437d8ead4187354e0c4807707248d6f773bd49245d96cde837b6c02330f508ff4b7108da687f298a73d43e546f0f27419180
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)
@@ -0,0 +1,207 @@
1
+ module CollavreGithub
2
+ class GithubPrChannel < Collavre::Channel
3
+ self.table_name = "channels"
4
+
5
+ def repo_full_name
6
+ config["repo_full_name"]
7
+ end
8
+
9
+ def pr_number
10
+ config["pr_number"].to_i
11
+ end
12
+
13
+ def pr_url
14
+ "https://github.com/#{repo_full_name}/pull/#{pr_number}"
15
+ end
16
+
17
+ def label
18
+ t("label", number: pr_number)
19
+ end
20
+
21
+ # Chip fallbacks: derived directly from config so the chip can render the
22
+ # full "PR #N" + URL immediately on attach, without waiting for the first
23
+ # webhook event to populate latest_label / latest_link.
24
+ def default_label
25
+ label
26
+ end
27
+
28
+ def default_link
29
+ pr_url
30
+ end
31
+
32
+ def badge_state
33
+ pr_state
34
+ end
35
+
36
+ def badge_title
37
+ I18n.t("collavre_github.channel.pr.badge.#{pr_state}", default: pr_state.to_s)
38
+ end
39
+
40
+ # PR lifecycle state used by the chip badge color. Defaults to "open" so
41
+ # freshly attached channels render the green badge before any close event
42
+ # has been received. Persisted in `config` to avoid a schema change for a
43
+ # channel-subtype-specific concern.
44
+ PR_STATES = %w[open merged closed_without_merge].freeze
45
+
46
+ def pr_state
47
+ state = config["pr_state"].to_s
48
+ PR_STATES.include?(state) ? state : "open"
49
+ end
50
+
51
+ # Symmetric with the reader: refuse to persist values outside PR_STATES
52
+ # rather than silently downgrading to "open" at read time. Without this
53
+ # any caller that mistypes (e.g. "merged_") would corrupt the badge color
54
+ # with no error surfaced.
55
+ def pr_state=(value)
56
+ value = value.to_s
57
+ raise ArgumentError, "Invalid pr_state: #{value.inspect}" unless PR_STATES.include?(value)
58
+ self.config = config.merge("pr_state" => value)
59
+ end
60
+
61
+ def attached_message
62
+ Collavre::Channel::InjectedMessage.new(
63
+ speaker: channel_bot_user,
64
+ message: t("attached_message", label: label, url: pr_url),
65
+ label: label,
66
+ link: pr_url
67
+ )
68
+ end
69
+
70
+ def reopened_message
71
+ Collavre::Channel::InjectedMessage.new(
72
+ speaker: channel_bot_user,
73
+ message: t("reopened_message", label: label, url: pr_url),
74
+ label: label,
75
+ link: pr_url
76
+ )
77
+ end
78
+
79
+ def handle(event:, payload:)
80
+ case event
81
+ when "issue_comment"
82
+ handle_issue_comment(payload)
83
+ when "pull_request_review_comment"
84
+ handle_review_comment(payload)
85
+ when "pull_request_review"
86
+ handle_review_submitted(payload)
87
+ when "pull_request"
88
+ handle_pull_request(payload)
89
+ end
90
+ end
91
+
92
+ private
93
+
94
+ def handle_pull_request(payload)
95
+ return nil unless payload["action"] == "closed"
96
+ pr = payload["pull_request"]
97
+ new_state = pr["merged"] ? "merged" : "closed_without_merge"
98
+ verb = pr["merged"] ? t("state_merged") : t("state_closed")
99
+
100
+ # pr_state is updated atomically with the closing comment: the dispatch
101
+ # path wraps both `handle` and `inject_into_topic!` in a single with_lock
102
+ # transaction, so an inject failure rolls this update back too. That is
103
+ # the intended behavior — we don't want the chip to flash merged/closed
104
+ # without the matching closing message in the timeline.
105
+ self.pr_state = new_state
106
+ save!
107
+
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
+ )
114
+ # Detach is performed by the webhook controller AFTER injecting this
115
+ # message, so the chip stays visible until the closing comment lands.
116
+ end
117
+
118
+ def handle_review_submitted(payload)
119
+ return nil unless payload["action"] == "submitted"
120
+ review = payload["review"]
121
+ body = review["body"].to_s
122
+ return nil if body.strip.empty?
123
+
124
+ author = review.dig("user", "login")
125
+ return nil if ignored_actor?(author)
126
+ state = review["state"]
127
+ Collavre::Channel::InjectedMessage.new(
128
+ speaker: channel_bot_user,
129
+ message: t("review_submitted_message", author: author, state: state, label: label, body: body),
130
+ label: label,
131
+ link: pr_url
132
+ )
133
+ end
134
+
135
+ def handle_review_comment(payload)
136
+ return nil unless payload["action"] == "created"
137
+ comment = payload["comment"]
138
+ author = comment.dig("user", "login")
139
+ return nil if ignored_actor?(author)
140
+ path = comment["path"]
141
+ line = comment["line"]
142
+ body = comment["body"].to_s
143
+
144
+ location =
145
+ if path && line
146
+ t("review_comment_location_path_line", path: path, line: line)
147
+ elsif path
148
+ t("review_comment_location_path", path: path)
149
+ else
150
+ ""
151
+ end
152
+ Collavre::Channel::InjectedMessage.new(
153
+ speaker: channel_bot_user,
154
+ message: t("review_comment_message", author: author, label: label, location: location, body: body),
155
+ label: label,
156
+ link: pr_url
157
+ )
158
+ end
159
+
160
+ def handle_issue_comment(payload)
161
+ return nil unless payload["action"] == "created"
162
+ return nil unless payload.dig("issue", "pull_request") # PR comments only
163
+
164
+ comment = payload["comment"]
165
+ author = comment.dig("user", "login")
166
+ return nil if ignored_actor?(author)
167
+ body = comment["body"].to_s
168
+
169
+ Collavre::Channel::InjectedMessage.new(
170
+ speaker: channel_bot_user,
171
+ message: t("comment_message", author: author, label: label, body: body),
172
+ label: label,
173
+ link: pr_url
174
+ )
175
+ end
176
+
177
+ def ignored_actor?(login)
178
+ Array(config["ignore_actor_logins"]).include?(login)
179
+ end
180
+
181
+ def t(key, **opts)
182
+ I18n.t("collavre_github.channel.pr.#{key}", **opts)
183
+ end
184
+
185
+ # Find the channel bot user. Falls back to creating the row when missing
186
+ # (e.g. migrations applied but `db:seed` was skipped). Without this
187
+ # fallback every PR webhook would raise RecordNotFound and the event
188
+ # would be silently dropped by the controller's rescue.
189
+ def channel_bot_user
190
+ @channel_bot_user ||=
191
+ Collavre::User.find_by(email: Collavre::Channel::BOT_EMAIL) ||
192
+ ensure_channel_bot_user!
193
+ end
194
+
195
+ def ensure_channel_bot_user!
196
+ email = Collavre::Channel::BOT_EMAIL
197
+ user = Collavre::User.find_or_initialize_by(email: email)
198
+ user.name = Collavre::Channel::BOT_NAME
199
+ user.password = SecureRandom.hex(32) if user.new_record?
200
+ user.email_verified_at ||= Time.current
201
+ user.searchable = false if user.respond_to?(:searchable=)
202
+ user.llm_vendor = nil
203
+ user.save!
204
+ user
205
+ end
206
+ end
207
+ end
@@ -162,12 +162,26 @@ module CollavreGithub
162
162
 
163
163
  attr_reader :client
164
164
 
165
- # Use GITHUB_API_ENDPOINT env var if set, otherwise fall back to mock server
166
- # in development when no real GitHub credentials are configured.
165
+ # Resolve the API endpoint via Resolver (DB > ENV), otherwise fall back to
166
+ # the mock server in development when no real GitHub credentials are
167
+ # configured.
167
168
  def resolve_api_endpoint
168
- return ENV["GITHUB_API_ENDPOINT"] if ENV["GITHUB_API_ENDPOINT"].present?
169
-
170
- if Rails.env.development? && ENV["GITHUB_CLIENT_ID"].blank?
169
+ endpoint =
170
+ if defined?(Collavre::IntegrationSettings::Resolver)
171
+ Collavre::IntegrationSettings::Resolver.get(:github_api_endpoint).presence
172
+ else
173
+ ENV["GITHUB_API_ENDPOINT"].presence
174
+ end
175
+ return endpoint if endpoint.present?
176
+
177
+ github_client_id =
178
+ if defined?(Collavre::IntegrationSettings::Resolver)
179
+ Collavre::IntegrationSettings::Resolver.get(:github_client_id).presence
180
+ else
181
+ ENV["GITHUB_CLIENT_ID"].presence
182
+ end
183
+
184
+ if Rails.env.development? && github_client_id.blank?
171
185
  MOCK_SERVER_DEFAULT
172
186
  end
173
187
  end
@@ -0,0 +1,11 @@
1
+ module CollavreGithub
2
+ class PrTopicLinkParser
3
+ TOPIC_RE = %r{/creatives/\d+/topics/(\d+)}.freeze
4
+
5
+ def self.call(body)
6
+ return nil if body.blank?
7
+ m = body.match(TOPIC_RE)
8
+ m && m[1].to_i
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CollavreGithub
4
+ module Tools
5
+ # Kept as an alias of the core engine's shared error so existing rescue
6
+ # blocks and tests in collavre_github continue to match. New code should
7
+ # reference `Collavre::Tools::PermissionDeniedError` directly.
8
+ PermissionDeniedError = ::Collavre::Tools::PermissionDeniedError
9
+ end
10
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sorbet-runtime"
4
+ require "rails_mcp_engine"
5
+
6
+ module CollavreGithub
7
+ module Tools
8
+ class PrMonitorService
9
+ extend T::Sig
10
+ extend ToolMeta
11
+
12
+ PR_URL_RE = %r{\Ahttps?://github\.com/([^/]+/[^/]+)/pull/(\d+)\z}.freeze
13
+
14
+ tool_name "pr_monitor"
15
+ tool_description <<~DESC.strip
16
+ Attach a GitHub PR monitor to a Collavre topic. After attachment,
17
+ PR comments, review comments, and review submissions are injected
18
+ into the topic as chat messages. Idempotent.
19
+ DESC
20
+
21
+ tool_param :topic_id, description: "The Collavre topic id to attach the PR channel to."
22
+ tool_param :pr_url, description: "Full GitHub PR URL, e.g. https://github.com/owner/repo/pull/123"
23
+
24
+ sig { params(topic_id: Integer, pr_url: String).returns(T::Hash[Symbol, T.untyped]) }
25
+ def call(topic_id:, pr_url:)
26
+ m = pr_url.match(PR_URL_RE)
27
+ raise ArgumentError, "Invalid PR URL: #{pr_url}" unless m
28
+ # GitHub owner/repo identifiers are case-insensitive but webhook payloads
29
+ # always carry the canonical case. Normalize on store so user input
30
+ # like "Owner/Repo" still matches incoming events.
31
+ repo = m[1].downcase
32
+ pr_number = m[2].to_i
33
+
34
+ topic = Collavre::Topic.find(topic_id)
35
+ Collavre::Tools::TopicAuthorizer.authorize_write!(topic)
36
+ channel, attach_status = find_or_attach_channel(topic, repo, pr_number)
37
+ # Re-seed announcement on fresh attach AND on detached->active so the
38
+ # chip label/link cache is repopulated after the channel was previously
39
+ # auto-detached (PR closed) and the user reattached it.
40
+ if attach_status == :created || attach_status == :reactivated
41
+ channel.inject_into_topic!(channel.attached_message)
42
+ end
43
+
44
+ result = { ok: true, channel_id: channel.id, repo: repo, pr_number: pr_number }
45
+ warning = ensure_webhook_events(topic, repo)
46
+ result[:webhook_warning] = warning if warning
47
+ result
48
+ end
49
+
50
+ private
51
+
52
+ # Wraps Collavre::ChannelAttacher with PR-specific create attrs and a
53
+ # PR-specific reactivation reset (pr_state back to "open"). The shared
54
+ # attacher handles the create/reactivate/noop lifecycle, including the
55
+ # concurrent insert race and dismissed_at clearing — Mirror
56
+ # WebhooksController#maybe_auto_attach_channel reactivation, so the
57
+ # chip resurfaces under the not_dismissed render scope.
58
+ sig { params(topic: Collavre::Topic, repo: String, pr_number: Integer).returns([ CollavreGithub::GithubPrChannel, Symbol ]) }
59
+ def find_or_attach_channel(topic, repo, pr_number)
60
+ Collavre::ChannelAttacher.call(
61
+ channel_class: CollavreGithub::GithubPrChannel,
62
+ lookup: -> { lookup_channel(topic, repo, pr_number) },
63
+ create_attrs: {
64
+ topic_id: topic.id,
65
+ config: { "repo_full_name" => repo, "pr_number" => pr_number, "pr_state" => "open" }
66
+ },
67
+ on_reactivate: ->(channel) {
68
+ channel.pr_state = "open" if channel.pr_state != "open"
69
+ }
70
+ )
71
+ end
72
+
73
+ sig { params(topic: Collavre::Topic, repo: String, pr_number: Integer).returns(T.nilable(CollavreGithub::GithubPrChannel)) }
74
+ def lookup_channel(topic, repo, pr_number)
75
+ CollavreGithub::GithubPrChannel.where(topic_id: topic.id).find do |c|
76
+ c.repo_full_name.to_s.downcase == repo.downcase && c.pr_number == pr_number
77
+ end
78
+ end
79
+
80
+ # Make sure the repo's webhook subscribes to the PR-channel events
81
+ # (issue_comment / pull_request_review / pull_request_review_comment).
82
+ # Without these, GitHub never delivers comment payloads and the channel
83
+ # silently misses them — exactly the bug that motivated this method.
84
+ # Returns a warning string when provisioning cannot run or fails; nil on
85
+ # success so the MCP response stays clean.
86
+ def ensure_webhook_events(topic, repo)
87
+ scoped_link = scoped_repository_link_for(topic, repo)
88
+ return "no RepositoryLink found for #{repo} in topic creative scope; webhook events not auto-provisioned" unless scoped_link
89
+
90
+ # Provision through the *global* primary link (lowest id across all
91
+ # creatives), not the scoped link. WebhookProvisioner only patches hook
92
+ # events when the link IS the primary; non-primary links short-circuit
93
+ # to secret alignment and skip the GitHub edit_hook call. So if we
94
+ # provisioned the scoped link and it was not the global primary, the
95
+ # existing hook would keep its old event list. The scoped link is only
96
+ # used as an authorization gate above.
97
+ provisioning_link = global_primary_repository_link_for(repo) || scoped_link
98
+
99
+ account = provisioning_link.github_account
100
+ return "RepositoryLink for #{repo} has no GitHub account; webhook events not auto-provisioned" unless account
101
+
102
+ results = CollavreGithub::WebhookProvisioner.ensure_for_links(
103
+ account: account,
104
+ links: [ provisioning_link ],
105
+ webhook_url: github_webhook_url
106
+ )
107
+ status = results.first&.last
108
+ # :failed means Client returned nil (Octokit/Faraday error rescued in
109
+ # CollavreGithub::Client). Surface that to the MCP caller so they know
110
+ # webhook events were not actually patched.
111
+ return "webhook provisioning failed: GitHub API rejected the hook request (see logs)" if status == :failed
112
+ nil
113
+ rescue => e
114
+ Rails.logger.warn("[pr_monitor] webhook provisioning failed for #{repo}: #{e.class}: #{e.message}")
115
+ "webhook provisioning failed: #{e.message}"
116
+ end
117
+
118
+ # Authorization gate: a RepositoryLink for `repo` must live in this
119
+ # topic's creative subtree (the topic creative itself or one of its
120
+ # ancestors). Without one, the dispatch path in WebhooksController would
121
+ # silently drop events for this topic anyway.
122
+ def scoped_repository_link_for(topic, repo)
123
+ creative = topic.creative
124
+ return nil unless creative
125
+
126
+ candidate_ids = [ creative.id ] + creative.ancestors.pluck(:id)
127
+ CollavreGithub::RepositoryLink
128
+ .where("LOWER(repository_full_name) = ?", repo.downcase)
129
+ .where(creative_id: candidate_ids)
130
+ .order(:id)
131
+ .first
132
+ end
133
+
134
+ # Mirrors WebhookProvisioner#primary_link_for: the lowest-id link for the
135
+ # repo across ALL creatives. This is the link whose secret + events the
136
+ # hook is aligned to, so we must provision through it to trigger an
137
+ # actual edit_hook call.
138
+ def global_primary_repository_link_for(repo)
139
+ CollavreGithub::RepositoryLink
140
+ .where("LOWER(repository_full_name) = ?", repo.downcase)
141
+ .order(:id)
142
+ .first
143
+ end
144
+
145
+ def github_webhook_url
146
+ CollavreGithub::Engine.routes.url_helpers.webhooks_url(
147
+ Rails.application.config.action_mailer.default_url_options
148
+ )
149
+ end
150
+ end
151
+ end
152
+ end
@@ -1,7 +1,13 @@
1
1
  module CollavreGithub
2
2
  class WebhookProvisioner
3
- EVENTS = %w[pull_request].freeze
4
- EVENTS_WITH_PUSH = %w[pull_request push].freeze
3
+ # PR channel webhooks need every event GithubPrChannel handles. Without
4
+ # `issue_comment` / `pull_request_review` / `pull_request_review_comment`
5
+ # GitHub never delivers the relevant deliveries, so pr_monitor would attach
6
+ # a channel that silently misses comments. `pull_request` is required by
7
+ # the auto-attach + close detection paths.
8
+ CHANNEL_EVENTS = %w[issue_comment pull_request_review pull_request_review_comment].freeze
9
+ EVENTS = (%w[pull_request] + CHANNEL_EVENTS).freeze
10
+ EVENTS_WITH_PUSH = (%w[pull_request push] + CHANNEL_EVENTS).freeze
5
11
  CONTENT_TYPE = "json".freeze
6
12
 
7
13
  def self.ensure_for_links(account:, links:, webhook_url:)
@@ -17,10 +23,15 @@ module CollavreGithub
17
23
  @webhook_url = webhook_url
18
24
  end
19
25
 
26
+ # Returns [[link, status], ...] so callers can detect silent GitHub
27
+ # rejections. status is one of:
28
+ # :created - new hook created
29
+ # :updated - existing hook patched (events/url/secret)
30
+ # :secret_aligned - non-primary link with existing hook; only the local
31
+ # RepositoryLink secret was aligned. No GitHub call.
32
+ # :failed - Octokit/Faraday error OR Client returned nil
20
33
  def ensure_for_links(links)
21
- links.each do |link|
22
- ensure_webhook(link)
23
- end
34
+ links.map { |link| [ link, ensure_webhook(link) ] }
24
35
  end
25
36
 
26
37
  def remove_for_repositories(repositories)
@@ -50,8 +61,9 @@ module CollavreGithub
50
61
  if hook
51
62
  if primary_link && primary_link != link
52
63
  align_link_secret(link, primary_link.webhook_secret)
64
+ :secret_aligned
53
65
  else
54
- update_webhook(repository_full_name, hook.id, link.webhook_secret)
66
+ update_webhook(repository_full_name, hook.id, link.webhook_secret) ? :updated : :failed
55
67
  end
56
68
  else
57
69
  secret = link.webhook_secret
@@ -61,12 +73,13 @@ module CollavreGithub
61
73
  align_link_secret(link, secret)
62
74
  end
63
75
 
64
- create_webhook(repository_full_name, secret)
76
+ create_webhook(repository_full_name, secret) ? :created : :failed
65
77
  end
66
78
  rescue Octokit::Error => e
67
79
  Rails.logger.warn(
68
80
  "GitHub webhook provisioning failed for #{repository_full_name}: #{e.message}"
69
81
  )
82
+ :failed
70
83
  end
71
84
 
72
85
  def remove_webhook(repository_full_name)
@@ -0,0 +1,11 @@
1
+ # Register collavre_github integration setting keys with the central registry.
2
+ # Registered eagerly (not in `to_prepare`) because `github_mock` is read at
3
+ # boot by `config/initializers/omniauth.rb`. The other keys (`webhook_secret`,
4
+ # `api_endpoint`) are resolved per request and don't strictly need eager
5
+ # registration, but we keep them together for clarity.
6
+ if defined?(Collavre::IntegrationSettings::Registry)
7
+ registry = Collavre::IntegrationSettings::Registry.instance
8
+ registry.register(:github_webhook_secret, category: "github", sensitive: true, requires_restart: false)
9
+ registry.register(:github_api_endpoint, category: "github", sensitive: false, requires_restart: false)
10
+ registry.register(:github_mock, category: "github", sensitive: false, requires_restart: true)
11
+ end
@@ -93,3 +93,21 @@ en:
93
93
  updated: "Updated"
94
94
  ready_for_review: "Ready for Review"
95
95
  event: "Event"
96
+ channel:
97
+ pr:
98
+ label: "PR #%{number}"
99
+ label_with_state: "%{label} (%{state})"
100
+ state_merged: "merged"
101
+ state_closed: "closed"
102
+ comment_message: "**@%{author}** commented on %{label}:\n\n%{body}"
103
+ review_comment_message: "**@%{author}** reviewed %{label}%{location}:\n\n%{body}"
104
+ review_comment_location_path: " on `%{path}`"
105
+ review_comment_location_path_line: " on `%{path}`:%{line}"
106
+ review_submitted_message: "**@%{author}** submitted a review (`%{state}`) on %{label}:\n\n%{body}"
107
+ closed_message: "%{label} was **%{verb}**. Detaching channel."
108
+ attached_message: "%{label} monitoring started.\n\n%{url}"
109
+ reopened_message: "%{label} was reopened. Monitoring resumed.\n\n%{url}"
110
+ badge:
111
+ open: "Open"
112
+ merged: "Merged"
113
+ closed_without_merge: "Closed without merging"
@@ -93,3 +93,21 @@ ko:
93
93
  updated: "업데이트됨"
94
94
  ready_for_review: "리뷰 준비됨"
95
95
  event: "이벤트"
96
+ channel:
97
+ pr:
98
+ label: "PR #%{number}"
99
+ label_with_state: "%{label} (%{state})"
100
+ state_merged: "머지됨"
101
+ state_closed: "닫힘"
102
+ comment_message: "**@%{author}**님이 %{label}에 댓글을 남겼습니다:\n\n%{body}"
103
+ review_comment_message: "**@%{author}**님이 %{label}%{location}을(를) 리뷰했습니다:\n\n%{body}"
104
+ review_comment_location_path: " (`%{path}`)"
105
+ review_comment_location_path_line: " (`%{path}`:%{line})"
106
+ review_submitted_message: "**@%{author}**님이 %{label}에 리뷰를 제출했습니다 (`%{state}`):\n\n%{body}"
107
+ closed_message: "%{label}이(가) **%{verb}** 되었습니다. 채널을 해제합니다."
108
+ attached_message: "%{label} 모니터링이 시작되었습니다.\n\n%{url}"
109
+ reopened_message: "%{label}이(가) 재오픈되어 모니터링이 재개되었습니다.\n\n%{url}"
110
+ badge:
111
+ open: "열림"
112
+ merged: "머지됨"
113
+ closed_without_merge: "머지 없이 닫힘"
data/db/seeds.rb CHANGED
@@ -76,6 +76,7 @@ module CollavreGithub
76
76
  github_pr_commits
77
77
  creative_retrieval_service
78
78
  creative_batch_service
79
+ pr_monitor
79
80
  ].freeze
80
81
 
81
82
  def self.call
@@ -1,3 +1,3 @@
1
1
  module CollavreGithub
2
- VERSION = "0.5.1"
2
+ VERSION = "0.6.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre_github
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -71,18 +71,23 @@ files:
71
71
  - app/jobs/collavre_github/markdown_sync_job.rb
72
72
  - app/models/collavre_github/account.rb
73
73
  - app/models/collavre_github/application_record.rb
74
+ - app/models/collavre_github/github_pr_channel.rb
74
75
  - app/models/collavre_github/repository_link.rb
75
76
  - app/services/collavre_github/client.rb
76
77
  - app/services/collavre_github/markdown_sync/content_processor.rb
77
78
  - app/services/collavre_github/markdown_sync/incremental_sync_service.rb
78
79
  - app/services/collavre_github/markdown_sync/initial_import_service.rb
80
+ - app/services/collavre_github/pr_topic_link_parser.rb
79
81
  - app/services/collavre_github/tools/concerns/github_client_finder.rb
80
82
  - app/services/collavre_github/tools/github_pr_commits_service.rb
81
83
  - app/services/collavre_github/tools/github_pr_details_service.rb
82
84
  - app/services/collavre_github/tools/github_pr_diff_service.rb
85
+ - app/services/collavre_github/tools/permission_denied_error.rb
86
+ - app/services/collavre_github/tools/pr_monitor_service.rb
83
87
  - app/services/collavre_github/webhook_provisioner.rb
84
88
  - app/views/collavre_github/auth/setup.html.erb
85
89
  - app/views/collavre_github/integrations/_modal.html.erb
90
+ - config/initializers/integration_settings.rb
86
91
  - config/locales/en.yml
87
92
  - config/locales/ko.yml
88
93
  - config/routes.rb