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 +4 -4
- data/app/controllers/collavre_github/webhooks_controller.rb +203 -4
- 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 +6 -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)
|
|
@@ -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
|
-
#
|
|
166
|
-
# in development when no real GitHub credentials are
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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,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
|
-
|
|
4
|
-
|
|
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.
|
|
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
|
data/config/locales/en.yml
CHANGED
|
@@ -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"
|
data/config/locales/ko.yml
CHANGED
|
@@ -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
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.
|
|
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
|