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.
Files changed (29) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +127 -0
  3. data/Rakefile +8 -0
  4. data/app/controllers/collavre_github/accounts_controller.rb +84 -0
  5. data/app/controllers/collavre_github/application_controller.rb +11 -0
  6. data/app/controllers/collavre_github/auth_controller.rb +27 -0
  7. data/app/controllers/collavre_github/creatives/integrations_controller.rb +181 -0
  8. data/app/controllers/collavre_github/webhooks_controller.rb +304 -0
  9. data/app/models/collavre_github/account.rb +11 -0
  10. data/app/models/collavre_github/application_record.rb +5 -0
  11. data/app/models/collavre_github/repository_link.rb +19 -0
  12. data/app/services/collavre_github/client.rb +110 -0
  13. data/app/services/collavre_github/tools/concerns/github_client_finder.rb +36 -0
  14. data/app/services/collavre_github/tools/github_pr_commits_service.rb +40 -0
  15. data/app/services/collavre_github/tools/github_pr_details_service.rb +53 -0
  16. data/app/services/collavre_github/tools/github_pr_diff_service.rb +57 -0
  17. data/app/services/collavre_github/webhook_provisioner.rb +128 -0
  18. data/app/views/collavre_github/integrations/_modal.html.erb +77 -0
  19. data/config/locales/en.yml +80 -0
  20. data/config/locales/ko.yml +80 -0
  21. data/config/routes.rb +17 -0
  22. data/db/migrate/20250925000000_create_github_integrations.rb +26 -0
  23. data/db/migrate/20250927000000_add_webhook_secret_to_github_repository_links.rb +29 -0
  24. data/db/migrate/20250928105957_add_github_gemini_prompt_to_creatives.rb +5 -0
  25. data/db/seeds.rb +117 -0
  26. data/lib/collavre_github/engine.rb +74 -0
  27. data/lib/collavre_github/version.rb +3 -0
  28. data/lib/collavre_github.rb +5 -0
  29. 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,5 @@
1
+ module CollavreGithub
2
+ class ApplicationRecord < ActiveRecord::Base
3
+ self.abstract_class = true
4
+ end
5
+ 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