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
|
@@ -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
|
|
@@ -66,22 +66,28 @@ files:
|
|
|
66
66
|
- app/controllers/collavre_github/auth_controller.rb
|
|
67
67
|
- app/controllers/collavre_github/creatives/integrations_controller.rb
|
|
68
68
|
- app/controllers/collavre_github/webhooks_controller.rb
|
|
69
|
+
- app/javascript/collavre_github.js
|
|
69
70
|
- app/jobs/collavre_github/initial_markdown_sync_job.rb
|
|
70
71
|
- app/jobs/collavre_github/markdown_sync_job.rb
|
|
71
72
|
- app/models/collavre_github/account.rb
|
|
72
73
|
- app/models/collavre_github/application_record.rb
|
|
74
|
+
- app/models/collavre_github/github_pr_channel.rb
|
|
73
75
|
- app/models/collavre_github/repository_link.rb
|
|
74
76
|
- app/services/collavre_github/client.rb
|
|
75
77
|
- app/services/collavre_github/markdown_sync/content_processor.rb
|
|
76
78
|
- app/services/collavre_github/markdown_sync/incremental_sync_service.rb
|
|
77
79
|
- app/services/collavre_github/markdown_sync/initial_import_service.rb
|
|
80
|
+
- app/services/collavre_github/pr_topic_link_parser.rb
|
|
78
81
|
- app/services/collavre_github/tools/concerns/github_client_finder.rb
|
|
79
82
|
- app/services/collavre_github/tools/github_pr_commits_service.rb
|
|
80
83
|
- app/services/collavre_github/tools/github_pr_details_service.rb
|
|
81
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
|
|
82
87
|
- app/services/collavre_github/webhook_provisioner.rb
|
|
83
88
|
- app/views/collavre_github/auth/setup.html.erb
|
|
84
89
|
- app/views/collavre_github/integrations/_modal.html.erb
|
|
90
|
+
- config/initializers/integration_settings.rb
|
|
85
91
|
- config/locales/en.yml
|
|
86
92
|
- config/locales/ko.yml
|
|
87
93
|
- config/routes.rb
|