collavre_github 0.2.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 +7 -0
- data/README.md +127 -0
- data/Rakefile +8 -0
- data/app/controllers/collavre_github/accounts_controller.rb +84 -0
- data/app/controllers/collavre_github/application_controller.rb +11 -0
- data/app/controllers/collavre_github/auth_controller.rb +27 -0
- data/app/controllers/collavre_github/creatives/integrations_controller.rb +181 -0
- data/app/controllers/collavre_github/webhooks_controller.rb +304 -0
- data/app/models/collavre_github/account.rb +11 -0
- data/app/models/collavre_github/application_record.rb +5 -0
- data/app/models/collavre_github/repository_link.rb +19 -0
- data/app/services/collavre_github/client.rb +110 -0
- data/app/services/collavre_github/tools/concerns/github_client_finder.rb +36 -0
- data/app/services/collavre_github/tools/github_pr_commits_service.rb +40 -0
- data/app/services/collavre_github/tools/github_pr_details_service.rb +53 -0
- data/app/services/collavre_github/tools/github_pr_diff_service.rb +57 -0
- data/app/services/collavre_github/webhook_provisioner.rb +128 -0
- data/app/views/collavre_github/integrations/_modal.html.erb +77 -0
- data/config/locales/en.yml +80 -0
- data/config/locales/ko.yml +80 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20250925000000_create_github_integrations.rb +26 -0
- data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +29 -0
- data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +5 -0
- data/db/seeds.rb +117 -0
- data/lib/collavre_github/engine.rb +74 -0
- data/lib/collavre_github/version.rb +3 -0
- data/lib/collavre_github.rb +5 -0
- metadata +112 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
module CollavreGithub
|
|
2
|
+
class WebhooksController < ActionController::API
|
|
3
|
+
def create
|
|
4
|
+
event = github_event_header
|
|
5
|
+
if event.blank?
|
|
6
|
+
Rails.logger.warn("GitHub event header missing; rejecting request")
|
|
7
|
+
return head :bad_request
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
raw_body = request.raw_post.presence || request.body.read
|
|
11
|
+
payload = parse_payload(raw_body)
|
|
12
|
+
@repository_link = find_repository_link(payload)
|
|
13
|
+
return head :unauthorized unless valid_signature?(raw_body)
|
|
14
|
+
|
|
15
|
+
payload = payload.presence || {}
|
|
16
|
+
create_system_comment(event, payload) if @repository_link&.creative
|
|
17
|
+
|
|
18
|
+
head :ok
|
|
19
|
+
rescue JSON::ParserError
|
|
20
|
+
head :bad_request
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def create_system_comment(event, payload)
|
|
26
|
+
creative = @repository_link.creative&.effective_origin
|
|
27
|
+
return unless creative
|
|
28
|
+
|
|
29
|
+
content = format_github_event(event, payload)
|
|
30
|
+
|
|
31
|
+
comment = creative.comments.create!(
|
|
32
|
+
user: nil,
|
|
33
|
+
content: content,
|
|
34
|
+
private: false
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
# Dispatch event for AI Agent routing
|
|
38
|
+
Collavre::SystemEvents::Dispatcher.dispatch("comment_created", {
|
|
39
|
+
comment: {
|
|
40
|
+
id: comment.id,
|
|
41
|
+
content: comment.content,
|
|
42
|
+
user_id: nil
|
|
43
|
+
},
|
|
44
|
+
creative: {
|
|
45
|
+
id: creative.id,
|
|
46
|
+
description: creative.description
|
|
47
|
+
},
|
|
48
|
+
topic: {
|
|
49
|
+
id: comment.topic_id
|
|
50
|
+
},
|
|
51
|
+
chat: {
|
|
52
|
+
content: comment.content
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def format_github_event(event, payload)
|
|
58
|
+
case event
|
|
59
|
+
when "pull_request"
|
|
60
|
+
format_pull_request(payload)
|
|
61
|
+
when "push"
|
|
62
|
+
format_push(payload)
|
|
63
|
+
when "issues"
|
|
64
|
+
format_issue(payload)
|
|
65
|
+
when "issue_comment"
|
|
66
|
+
format_issue_comment(payload)
|
|
67
|
+
else
|
|
68
|
+
format_generic_event(event, payload)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def format_pull_request(payload)
|
|
73
|
+
pr = payload["pull_request"] || {}
|
|
74
|
+
action = payload["action"]
|
|
75
|
+
number = pr["number"]
|
|
76
|
+
title = pr["title"]
|
|
77
|
+
url = pr["html_url"]
|
|
78
|
+
user = pr.dig("user", "login")
|
|
79
|
+
merged = pr["merged"]
|
|
80
|
+
repo = payload.dig("repository", "full_name")
|
|
81
|
+
t = method(:t_webhook)
|
|
82
|
+
|
|
83
|
+
lines = []
|
|
84
|
+
lines << "### #{t.call('pull_request.title', action: action_label(action, merged))}"
|
|
85
|
+
lines << ""
|
|
86
|
+
lines << "**#{t.call('pull_request.repository')}:** #{repo}"
|
|
87
|
+
lines << "**#{t.call('pull_request.pr')}:** [##{number} #{title}](#{url})"
|
|
88
|
+
lines << "**#{t.call('pull_request.author')}:** #{user}"
|
|
89
|
+
lines << "**#{t.call('pull_request.action')}:** #{action}#{merged ? " #{t.call('pull_request.merged')}" : ''}"
|
|
90
|
+
|
|
91
|
+
if pr["body"].present?
|
|
92
|
+
lines << ""
|
|
93
|
+
lines << "**#{t.call('pull_request.description')}:**"
|
|
94
|
+
lines << pr["body"].to_s.truncate(500)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
lines.join("\n")
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def format_push(payload)
|
|
101
|
+
repo = payload.dig("repository", "full_name")
|
|
102
|
+
ref = payload["ref"]
|
|
103
|
+
branch = ref&.sub("refs/heads/", "")
|
|
104
|
+
pusher = payload.dig("pusher", "name")
|
|
105
|
+
commits = payload["commits"] || []
|
|
106
|
+
t = method(:t_webhook)
|
|
107
|
+
|
|
108
|
+
lines = []
|
|
109
|
+
lines << "### #{t.call('push.title', branch: branch)}"
|
|
110
|
+
lines << ""
|
|
111
|
+
lines << "**#{t.call('push.repository')}:** #{repo}"
|
|
112
|
+
lines << "**#{t.call('push.branch')}:** #{branch}"
|
|
113
|
+
lines << "**#{t.call('push.pusher')}:** #{pusher}"
|
|
114
|
+
lines << "**#{t.call('push.commits')}:** #{commits.size}"
|
|
115
|
+
|
|
116
|
+
if commits.any?
|
|
117
|
+
lines << ""
|
|
118
|
+
lines << "**#{t.call('push.recent_commits')}:**"
|
|
119
|
+
commits.first(5).each do |commit|
|
|
120
|
+
message = commit["message"].to_s.lines.first&.strip || t.call("push.no_message")
|
|
121
|
+
sha = commit["id"].to_s[0, 7]
|
|
122
|
+
lines << "- `#{sha}` #{message.truncate(80)}"
|
|
123
|
+
end
|
|
124
|
+
lines << "- #{t.call('push.more')}" if commits.size > 5
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
lines.join("\n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def format_issue(payload)
|
|
131
|
+
issue = payload["issue"] || {}
|
|
132
|
+
action = payload["action"]
|
|
133
|
+
number = issue["number"]
|
|
134
|
+
title = issue["title"]
|
|
135
|
+
url = issue["html_url"]
|
|
136
|
+
user = issue.dig("user", "login")
|
|
137
|
+
repo = payload.dig("repository", "full_name")
|
|
138
|
+
t = method(:t_webhook)
|
|
139
|
+
|
|
140
|
+
lines = []
|
|
141
|
+
lines << "### #{t.call('issue.title', action: action)}"
|
|
142
|
+
lines << ""
|
|
143
|
+
lines << "**#{t.call('issue.repository')}:** #{repo}"
|
|
144
|
+
lines << "**#{t.call('issue.issue')}:** [##{number} #{title}](#{url})"
|
|
145
|
+
lines << "**#{t.call('issue.author')}:** #{user}"
|
|
146
|
+
lines << "**#{t.call('issue.action')}:** #{action}"
|
|
147
|
+
|
|
148
|
+
if action == "opened" && issue["body"].present?
|
|
149
|
+
lines << ""
|
|
150
|
+
lines << "**#{t.call('issue.description')}:**"
|
|
151
|
+
lines << issue["body"].to_s.truncate(500)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
lines.join("\n")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def format_issue_comment(payload)
|
|
158
|
+
issue = payload["issue"] || {}
|
|
159
|
+
comment = payload["comment"] || {}
|
|
160
|
+
action = payload["action"]
|
|
161
|
+
number = issue["number"]
|
|
162
|
+
title = issue["title"]
|
|
163
|
+
url = comment["html_url"]
|
|
164
|
+
user = comment.dig("user", "login")
|
|
165
|
+
repo = payload.dig("repository", "full_name")
|
|
166
|
+
t = method(:t_webhook)
|
|
167
|
+
|
|
168
|
+
lines = []
|
|
169
|
+
lines << "### #{t.call('issue_comment.title', action: action, number: number)}"
|
|
170
|
+
lines << ""
|
|
171
|
+
lines << "**#{t.call('issue_comment.repository')}:** #{repo}"
|
|
172
|
+
lines << "**#{t.call('issue_comment.issue')}:** ##{number} #{title}"
|
|
173
|
+
lines << "**#{t.call('issue_comment.comment_by')}:** #{user}"
|
|
174
|
+
lines << "**#{t.call('issue_comment.link')}:** [#{t.call('issue_comment.view_comment')}](#{url})"
|
|
175
|
+
|
|
176
|
+
if comment["body"].present?
|
|
177
|
+
lines << ""
|
|
178
|
+
lines << "**#{t.call('issue_comment.comment')}:**"
|
|
179
|
+
lines << comment["body"].to_s.truncate(500)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
lines.join("\n")
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def format_generic_event(event, payload)
|
|
186
|
+
repo = payload.dig("repository", "full_name")
|
|
187
|
+
action = payload["action"]
|
|
188
|
+
sender = payload.dig("sender", "login")
|
|
189
|
+
t = method(:t_webhook)
|
|
190
|
+
|
|
191
|
+
lines = []
|
|
192
|
+
lines << "### #{t.call('generic.title', event: event.titleize)}"
|
|
193
|
+
lines << ""
|
|
194
|
+
lines << "**#{t.call('generic.repository')}:** #{repo}"
|
|
195
|
+
lines << "**#{t.call('generic.action')}:** #{action}" if action
|
|
196
|
+
lines << "**#{t.call('generic.sender')}:** #{sender}" if sender
|
|
197
|
+
|
|
198
|
+
lines.join("\n")
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def action_label(action, merged)
|
|
202
|
+
t = method(:t_webhook)
|
|
203
|
+
case action
|
|
204
|
+
when "opened"
|
|
205
|
+
t.call("actions.opened")
|
|
206
|
+
when "closed"
|
|
207
|
+
merged ? t.call("actions.merged") : t.call("actions.closed")
|
|
208
|
+
when "reopened"
|
|
209
|
+
t.call("actions.reopened")
|
|
210
|
+
when "synchronize"
|
|
211
|
+
t.call("actions.updated")
|
|
212
|
+
when "ready_for_review"
|
|
213
|
+
t.call("actions.ready_for_review")
|
|
214
|
+
else
|
|
215
|
+
action&.titleize || t.call("actions.event")
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def t_webhook(key, **options)
|
|
220
|
+
I18n.t("collavre_github.webhooks.#{key}", **options)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def find_repository_link(payload)
|
|
224
|
+
if payload.blank?
|
|
225
|
+
Rails.logger.warn("[GitHub Webhook] Payload is blank")
|
|
226
|
+
return
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
repo = payload["repository"] || payload[:repository]
|
|
230
|
+
if repo.blank?
|
|
231
|
+
Rails.logger.warn("[GitHub Webhook] Repository missing in payload")
|
|
232
|
+
return
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
full_name = repo["full_name"] || repo[:full_name]
|
|
236
|
+
if full_name.blank?
|
|
237
|
+
Rails.logger.warn("[GitHub Webhook] Repository full_name missing in payload")
|
|
238
|
+
return
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
CollavreGithub::RepositoryLink.find_by(repository_full_name: full_name)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def valid_signature?(raw_body)
|
|
245
|
+
secret = webhook_secret
|
|
246
|
+
signature_header = request.headers["X-Hub-Signature-256"] || request.headers["X-Hub-Signature"]
|
|
247
|
+
|
|
248
|
+
if secret.blank?
|
|
249
|
+
Rails.logger.warn("GitHub webhook secret missing; rejecting request")
|
|
250
|
+
return false
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
return false if signature_header.blank?
|
|
254
|
+
|
|
255
|
+
algorithm =
|
|
256
|
+
if signature_header.start_with?("sha256=")
|
|
257
|
+
"sha256"
|
|
258
|
+
elsif signature_header.start_with?("sha1=")
|
|
259
|
+
"sha1"
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
return false if algorithm.blank?
|
|
263
|
+
|
|
264
|
+
digest = OpenSSL::HMAC.hexdigest(algorithm.upcase, secret, raw_body)
|
|
265
|
+
expected_signature = "#{algorithm}=#{digest}"
|
|
266
|
+
|
|
267
|
+
ActiveSupport::SecurityUtils.secure_compare(expected_signature, signature_header)
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def webhook_secret
|
|
271
|
+
@repository_link&.webhook_secret || fallback_webhook_secret
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def fallback_webhook_secret
|
|
275
|
+
ENV["GITHUB_WEBHOOK_SECRET"] || Rails.application.credentials.dig(:github, :webhook_secret)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def parse_payload(raw_body)
|
|
279
|
+
params = request.request_parameters
|
|
280
|
+
parsed_params =
|
|
281
|
+
case params
|
|
282
|
+
when ActionController::Parameters
|
|
283
|
+
params.to_unsafe_h
|
|
284
|
+
else
|
|
285
|
+
params
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
if parsed_params.present?
|
|
289
|
+
wrapper_payload = parsed_params.with_indifferent_access[:payload]
|
|
290
|
+
return wrapper_payload if wrapper_payload.is_a?(Hash)
|
|
291
|
+
return JSON.parse(wrapper_payload) if wrapper_payload.is_a?(String)
|
|
292
|
+
|
|
293
|
+
return parsed_params
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
raw_body.present? ? JSON.parse(raw_body) : nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def github_event_header
|
|
300
|
+
request.headers["X-GitHub-Event"].presence ||
|
|
301
|
+
request.get_header("HTTP_X_GITHUB_EVENT").presence
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module CollavreGithub
|
|
2
|
+
class Account < ApplicationRecord
|
|
3
|
+
self.table_name = "github_accounts"
|
|
4
|
+
|
|
5
|
+
belongs_to :user, class_name: "::User"
|
|
6
|
+
has_many :repository_links, class_name: "CollavreGithub::RepositoryLink",
|
|
7
|
+
foreign_key: :github_account_id, dependent: :destroy
|
|
8
|
+
|
|
9
|
+
encrypts :token, deterministic: false
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module CollavreGithub
|
|
2
|
+
class RepositoryLink < ApplicationRecord
|
|
3
|
+
self.table_name = "github_repository_links"
|
|
4
|
+
|
|
5
|
+
belongs_to :creative, class_name: "Collavre::Creative"
|
|
6
|
+
belongs_to :github_account, class_name: "CollavreGithub::Account"
|
|
7
|
+
|
|
8
|
+
validates :repository_full_name, presence: true
|
|
9
|
+
validates :webhook_secret, presence: true
|
|
10
|
+
|
|
11
|
+
before_validation :ensure_webhook_secret
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def ensure_webhook_secret
|
|
16
|
+
self.webhook_secret ||= SecureRandom.hex(20)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
module CollavreGithub
|
|
2
|
+
class Client
|
|
3
|
+
def initialize(account)
|
|
4
|
+
@client = Octokit::Client.new(access_token: account.token)
|
|
5
|
+
@client.auto_paginate = true
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def organizations
|
|
9
|
+
client.organizations
|
|
10
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
11
|
+
Rails.logger.warn("GitHub organizations fetch failed: #{e.message}")
|
|
12
|
+
[]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def repositories_for_authenticated_user
|
|
16
|
+
client.repos(nil, type: "all")
|
|
17
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
18
|
+
Rails.logger.warn("GitHub user repos fetch failed: #{e.message}")
|
|
19
|
+
[]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def repositories_for_organization(org)
|
|
23
|
+
client.org_repos(org, type: "all")
|
|
24
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
25
|
+
Rails.logger.warn("GitHub org repos fetch failed: #{e.message}")
|
|
26
|
+
[]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def pull_request_details(repo_full_name, number)
|
|
30
|
+
client.pull_request(repo_full_name, number)
|
|
31
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
32
|
+
Rails.logger.warn("GitHub PR fetch failed: #{e.message}")
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pull_request_commit_messages(repo_full_name, number)
|
|
37
|
+
client
|
|
38
|
+
.pull_request_commits(repo_full_name, number)
|
|
39
|
+
.map { |commit| commit.commit&.message }
|
|
40
|
+
.compact
|
|
41
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
42
|
+
Rails.logger.warn("GitHub PR commits fetch failed: #{e.message}")
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def pull_request_diff(repo_full_name, number)
|
|
47
|
+
files = client.pull_request_files(repo_full_name, number)
|
|
48
|
+
formatted = files.filter_map do |file|
|
|
49
|
+
next unless file.patch.present?
|
|
50
|
+
|
|
51
|
+
<<~DIFF.strip
|
|
52
|
+
diff --git a/#{file.filename} b/#{file.filename}
|
|
53
|
+
#{file.patch}
|
|
54
|
+
DIFF
|
|
55
|
+
end
|
|
56
|
+
formatted.join("\n\n").presence
|
|
57
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
58
|
+
Rails.logger.warn("GitHub PR files fetch failed: #{e.message}")
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def repository_hooks(repo_full_name)
|
|
63
|
+
client.hooks(repo_full_name)
|
|
64
|
+
rescue Octokit::Error, Faraday::Error => e
|
|
65
|
+
Rails.logger.warn("GitHub hooks fetch failed for #{repo_full_name}: #{e.message}")
|
|
66
|
+
[]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def create_repository_webhook(repo_full_name, url:, secret:, events:, content_type: "json")
|
|
70
|
+
client.create_hook(
|
|
71
|
+
repo_full_name,
|
|
72
|
+
"web",
|
|
73
|
+
{
|
|
74
|
+
url: url,
|
|
75
|
+
content_type: content_type,
|
|
76
|
+
secret: secret
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
events: events,
|
|
80
|
+
active: true
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def update_repository_webhook(repo_full_name, hook_id, url:, secret:, events:, content_type: "json")
|
|
86
|
+
client.edit_hook(
|
|
87
|
+
repo_full_name,
|
|
88
|
+
hook_id,
|
|
89
|
+
"web",
|
|
90
|
+
{
|
|
91
|
+
url: url,
|
|
92
|
+
content_type: content_type,
|
|
93
|
+
secret: secret
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
events: events,
|
|
97
|
+
active: true
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def delete_repository_webhook(repo_full_name, hook_id)
|
|
103
|
+
client.remove_hook(repo_full_name, hook_id)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
attr_reader :client
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CollavreGithub
|
|
4
|
+
module Tools
|
|
5
|
+
module Concerns
|
|
6
|
+
# Shared logic for finding GitHub client from Creative context
|
|
7
|
+
module GithubClientFinder
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
# Find GitHub client for a creative and repository
|
|
13
|
+
# Traverses from creative to its origin, then finds the linked repository
|
|
14
|
+
#
|
|
15
|
+
# @param creative_id [Integer] The creative ID to start from
|
|
16
|
+
# @param repo [String] Repository full name (e.g., "owner/repo")
|
|
17
|
+
# @return [CollavreGithub::Client, nil] GitHub client or nil if not found
|
|
18
|
+
def find_github_client(creative_id, repo)
|
|
19
|
+
creative = Collavre::Creative.find_by(id: creative_id)
|
|
20
|
+
return nil unless creative
|
|
21
|
+
|
|
22
|
+
origin = creative.effective_origin
|
|
23
|
+
link = CollavreGithub::RepositoryLink
|
|
24
|
+
.joins(:creative)
|
|
25
|
+
.where(repository_full_name: repo)
|
|
26
|
+
.where(creative: origin.self_and_descendants)
|
|
27
|
+
.first
|
|
28
|
+
|
|
29
|
+
return nil unless link&.github_account
|
|
30
|
+
|
|
31
|
+
CollavreGithub::Client.new(link.github_account)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sorbet-runtime"
|
|
4
|
+
require "rails_mcp_engine"
|
|
5
|
+
|
|
6
|
+
module CollavreGithub
|
|
7
|
+
module Tools
|
|
8
|
+
class GithubPrCommitsService
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend ToolMeta
|
|
11
|
+
include Concerns::GithubClientFinder
|
|
12
|
+
|
|
13
|
+
tool_name "github_pr_commits"
|
|
14
|
+
tool_description <<~DESC.strip
|
|
15
|
+
Get the commit messages of a GitHub pull request.
|
|
16
|
+
Returns a list of commit messages in the PR.
|
|
17
|
+
Requires the creative to have a connected GitHub repository.
|
|
18
|
+
DESC
|
|
19
|
+
|
|
20
|
+
tool_param :creative_id, description: "The ID of the creative with GitHub integration."
|
|
21
|
+
tool_param :repo, description: "Repository full name (e.g., 'owner/repo')."
|
|
22
|
+
tool_param :pr_number, description: "Pull request number."
|
|
23
|
+
|
|
24
|
+
sig { params(creative_id: Integer, repo: String, pr_number: Integer).returns(T::Hash[Symbol, T.untyped]) }
|
|
25
|
+
def call(creative_id:, repo:, pr_number:)
|
|
26
|
+
client = find_github_client(creative_id, repo)
|
|
27
|
+
return { error: "GitHub account not found for this creative and repository" } unless client
|
|
28
|
+
|
|
29
|
+
messages = client.pull_request_commit_messages(repo, pr_number)
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
commits: messages.map.with_index(1) { |msg, i| { index: i, message: msg } },
|
|
33
|
+
count: messages.size
|
|
34
|
+
}
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
{ error: "Failed to fetch PR commits: #{e.message}" }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sorbet-runtime"
|
|
4
|
+
require "rails_mcp_engine"
|
|
5
|
+
|
|
6
|
+
module CollavreGithub
|
|
7
|
+
module Tools
|
|
8
|
+
class GithubPrDetailsService
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend ToolMeta
|
|
11
|
+
include Concerns::GithubClientFinder
|
|
12
|
+
|
|
13
|
+
tool_name "github_pr_details"
|
|
14
|
+
tool_description <<~DESC.strip
|
|
15
|
+
Get details of a GitHub pull request including title, body, author, files changed, etc.
|
|
16
|
+
Requires the creative to have a connected GitHub repository.
|
|
17
|
+
DESC
|
|
18
|
+
|
|
19
|
+
tool_param :creative_id, description: "The ID of the creative with GitHub integration."
|
|
20
|
+
tool_param :repo, description: "Repository full name (e.g., 'owner/repo')."
|
|
21
|
+
tool_param :pr_number, description: "Pull request number."
|
|
22
|
+
|
|
23
|
+
sig { params(creative_id: Integer, repo: String, pr_number: Integer).returns(T::Hash[Symbol, T.untyped]) }
|
|
24
|
+
def call(creative_id:, repo:, pr_number:)
|
|
25
|
+
client = find_github_client(creative_id, repo)
|
|
26
|
+
return { error: "GitHub account not found for this creative and repository" } unless client
|
|
27
|
+
|
|
28
|
+
pr = client.pull_request_details(repo, pr_number)
|
|
29
|
+
return { error: "Pull request not found" } unless pr
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
number: pr.number,
|
|
33
|
+
title: pr.title,
|
|
34
|
+
body: pr.body.to_s.truncate(2000),
|
|
35
|
+
state: pr.state,
|
|
36
|
+
merged: pr.merged,
|
|
37
|
+
author: pr.user&.login,
|
|
38
|
+
created_at: pr.created_at&.iso8601,
|
|
39
|
+
updated_at: pr.updated_at&.iso8601,
|
|
40
|
+
merged_at: pr.merged_at&.iso8601,
|
|
41
|
+
additions: pr.additions,
|
|
42
|
+
deletions: pr.deletions,
|
|
43
|
+
changed_files: pr.changed_files,
|
|
44
|
+
html_url: pr.html_url,
|
|
45
|
+
base_branch: pr.base&.ref,
|
|
46
|
+
head_branch: pr.head&.ref
|
|
47
|
+
}
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
{ error: "Failed to fetch PR details: #{e.message}" }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sorbet-runtime"
|
|
4
|
+
require "rails_mcp_engine"
|
|
5
|
+
|
|
6
|
+
module CollavreGithub
|
|
7
|
+
module Tools
|
|
8
|
+
class GithubPrDiffService
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend ToolMeta
|
|
11
|
+
include Concerns::GithubClientFinder
|
|
12
|
+
|
|
13
|
+
DIFF_MAX_LENGTH = 10_000
|
|
14
|
+
|
|
15
|
+
tool_name "github_pr_diff"
|
|
16
|
+
tool_description <<~DESC.strip
|
|
17
|
+
Get the diff of a GitHub pull request.
|
|
18
|
+
Returns the patch/diff for all changed files.
|
|
19
|
+
Requires the creative to have a connected GitHub repository.
|
|
20
|
+
DESC
|
|
21
|
+
|
|
22
|
+
tool_param :creative_id, description: "The ID of the creative with GitHub integration."
|
|
23
|
+
tool_param :repo, description: "Repository full name (e.g., 'owner/repo')."
|
|
24
|
+
tool_param :pr_number, description: "Pull request number."
|
|
25
|
+
tool_param :max_length, description: "Maximum diff length (default: 10000 chars).", required: false
|
|
26
|
+
|
|
27
|
+
sig do
|
|
28
|
+
params(
|
|
29
|
+
creative_id: Integer,
|
|
30
|
+
repo: String,
|
|
31
|
+
pr_number: Integer,
|
|
32
|
+
max_length: T.nilable(Integer)
|
|
33
|
+
).returns(T::Hash[Symbol, T.untyped])
|
|
34
|
+
end
|
|
35
|
+
def call(creative_id:, repo:, pr_number:, max_length: nil)
|
|
36
|
+
max_length ||= DIFF_MAX_LENGTH
|
|
37
|
+
|
|
38
|
+
client = find_github_client(creative_id, repo)
|
|
39
|
+
return { error: "GitHub account not found for this creative and repository" } unless client
|
|
40
|
+
|
|
41
|
+
diff = client.pull_request_diff(repo, pr_number)
|
|
42
|
+
return { error: "Could not fetch diff", diff: nil } unless diff
|
|
43
|
+
|
|
44
|
+
truncated = diff.length > max_length
|
|
45
|
+
diff_text = truncated ? "#{diff[0, max_length]}\n...\n[Diff truncated to #{max_length} characters]" : diff
|
|
46
|
+
|
|
47
|
+
{
|
|
48
|
+
diff: diff_text,
|
|
49
|
+
truncated: truncated,
|
|
50
|
+
original_length: diff.length
|
|
51
|
+
}
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
{ error: "Failed to fetch PR diff: #{e.message}" }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|