rails_nexus 2.0.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/MIT-LICENSE +20 -0
- data/README.md +430 -0
- data/Rakefile +6 -0
- data/app/assets/config/rails_nexus_manifest.js +2 -0
- data/app/assets/javascripts/rails_nexus/controllers.js +603 -0
- data/app/assets/stylesheets/rails_nexus/application.css +1964 -0
- data/app/controllers/rails_nexus/analytics_controller.rb +361 -0
- data/app/controllers/rails_nexus/application_controller.rb +15 -0
- data/app/controllers/rails_nexus/backup_controller.rb +91 -0
- data/app/controllers/rails_nexus/cron_jobs_controller.rb +64 -0
- data/app/controllers/rails_nexus/database_health_controller.rb +128 -0
- data/app/controllers/rails_nexus/logged_exceptions_controller.rb +227 -0
- data/app/controllers/rails_nexus/n1_patterns_controller.rb +10 -0
- data/app/controllers/rails_nexus/settings_controller.rb +100 -0
- data/app/controllers/rails_nexus/source_code_controller.rb +39 -0
- data/app/controllers/rails_nexus/stats_controller.rb +494 -0
- data/app/controllers/rails_nexus/workflow_controller.rb +96 -0
- data/app/helpers/rails_nexus/application_helper.rb +5 -0
- data/app/helpers/rails_nexus/logged_exceptions_helper.rb +254 -0
- data/app/helpers/rails_nexus/source_code_helper.rb +107 -0
- data/app/javascript/controllers/rails_nexus_controller.js +380 -0
- data/app/javascript/controllers/rails_nexus_detail_controller.js +165 -0
- data/app/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
- data/app/javascript/controllers/rails_nexus_theme_controller.js +31 -0
- data/app/javascript/rails_nexus/application.js +17 -0
- data/app/jobs/rails_nexus/application_job.rb +4 -0
- data/app/mailers/rails_nexus/application_mailer.rb +6 -0
- data/app/models/rails_nexus/application_record.rb +5 -0
- data/app/models/rails_nexus/backup.rb +107 -0
- data/app/models/rails_nexus/base_record.rb +26 -0
- data/app/models/rails_nexus/comment.rb +13 -0
- data/app/models/rails_nexus/cron_job.rb +72 -0
- data/app/models/rails_nexus/database_stat.rb +128 -0
- data/app/models/rails_nexus/event.rb +101 -0
- data/app/models/rails_nexus/logged_exception.rb +864 -0
- data/app/models/rails_nexus/logged_exception_n1.rb +87 -0
- data/app/models/rails_nexus/metric.rb +107 -0
- data/app/models/rails_nexus/nginx_metric.rb +112 -0
- data/app/models/rails_nexus/server_metric.rb +168 -0
- data/app/models/rails_nexus/webhook_delivery.rb +64 -0
- data/app/services/rails_nexus/backup_service.rb +275 -0
- data/app/views/layouts/rails_nexus/application.html.erb +185 -0
- data/app/views/rails_nexus/analytics/index.html.erb +363 -0
- data/app/views/rails_nexus/backup/files.html.erb +53 -0
- data/app/views/rails_nexus/backup/index.html.erb +211 -0
- data/app/views/rails_nexus/backup/settings.html.erb +158 -0
- data/app/views/rails_nexus/cron_jobs/index.html.erb +141 -0
- data/app/views/rails_nexus/database_health/index.html.erb +139 -0
- data/app/views/rails_nexus/logged_exceptions/_comments_list.html.erb +50 -0
- data/app/views/rails_nexus/logged_exceptions/_exceptions.html.erb +190 -0
- data/app/views/rails_nexus/logged_exceptions/_feed.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/_filter_group.html.erb +10 -0
- data/app/views/rails_nexus/logged_exceptions/_show.html.erb +554 -0
- data/app/views/rails_nexus/logged_exceptions/_workflow_panel.html.erb +118 -0
- data/app/views/rails_nexus/logged_exceptions/clear.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/destroy.turbo_stream.erb +2 -0
- data/app/views/rails_nexus/logged_exceptions/destroy_all.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/feed.rss.builder +20 -0
- data/app/views/rails_nexus/logged_exceptions/index.html.erb +323 -0
- data/app/views/rails_nexus/logged_exceptions/query.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/query.turbo_stream.erb +6 -0
- data/app/views/rails_nexus/logged_exceptions/show.html.erb +3 -0
- data/app/views/rails_nexus/logged_exceptions/show.turbo_stream.erb +5 -0
- data/app/views/rails_nexus/n1_patterns/index.html.erb +153 -0
- data/app/views/rails_nexus/settings/index.html.erb +140 -0
- data/app/views/rails_nexus/stats/index.html.erb +569 -0
- data/config/importmap.rb +15 -0
- data/config/initializers/date_formats.rb +5 -0
- data/config/initializers/rails_nexus.rb +18 -0
- data/config/locales/en.yml +47 -0
- data/config/routes.rb +58 -0
- data/db/migrate/20240330122311_create_rails_nexus_logged_exceptions.rb +22 -0
- data/lib/generators/rails_nexus/backup_generator.rb +70 -0
- data/lib/generators/rails_nexus/customize_generator.rb +159 -0
- data/lib/generators/rails_nexus/install_generator.rb +61 -0
- data/lib/generators/rails_nexus/templates/INSTALL.md +9 -0
- data/lib/generators/rails_nexus/templates/backup/backup_helper.sh +152 -0
- data/lib/generators/rails_nexus/templates/backup/config.rb +27 -0
- data/lib/generators/rails_nexus/templates/backup/models/daily_backup.rb +24 -0
- data/lib/generators/rails_nexus/templates/backup/models/full_backup.rb +35 -0
- data/lib/generators/rails_nexus/templates/backup/models/sync_backup.rb +43 -0
- data/lib/generators/rails_nexus/templates/backup/mysql-config/db_config.cnf +15 -0
- data/lib/generators/rails_nexus/templates/backup/schedule.rb +28 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_controller.js +310 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_detail_controller.js +98 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_sidebar_controller.js +15 -0
- data/lib/generators/rails_nexus/templates/javascript/controllers/rails_nexus_theme_controller.js +30 -0
- data/lib/generators/rails_nexus/templates/migration.rb +24 -0
- data/lib/generators/rails_nexus/templates/migration_advanced_features.rb +30 -0
- data/lib/generators/rails_nexus/templates/migration_cron_jobs.rb +22 -0
- data/lib/generators/rails_nexus/templates/migration_new_schema.rb +120 -0
- data/lib/generators/rails_nexus/templates/migration_platform_detection.rb +11 -0
- data/lib/generators/rails_nexus/templates/migration_webhook_deliveries.rb +21 -0
- data/lib/generators/rails_nexus/templates/migration_workflow.rb +29 -0
- data/lib/generators/rails_nexus/templates/rails_nexus.rb +96 -0
- data/lib/generators/rails_nexus/templates/views/layouts/rails_nexus/application.html.erb +129 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_exceptions.html.erb +123 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/_show.html.erb +297 -0
- data/lib/generators/rails_nexus/templates/views/rails_nexus/logged_exceptions/index.html.erb +113 -0
- data/lib/rails_nexus/breadcrumbs.rb +161 -0
- data/lib/rails_nexus/cleanup.rb +32 -0
- data/lib/rails_nexus/configuration.rb +319 -0
- data/lib/rails_nexus/engine.rb +135 -0
- data/lib/rails_nexus/logger.rb +188 -0
- data/lib/rails_nexus/notifications.rb +105 -0
- data/lib/rails_nexus/ransack_config.rb +4 -0
- data/lib/rails_nexus/storm_protection.rb +124 -0
- data/lib/rails_nexus/version.rb +3 -0
- data/lib/rails_nexus.rb +133 -0
- data/lib/tasks/rails_nexus.rake +230 -0
- data/lib/tasks/rails_nexus_tasks.rake +37 -0
- data/rails_nexus.gemspec +36 -0
- metadata +252 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
module LoggedExceptionsHelper
|
|
5
|
+
def pretty_exception_date(exception)
|
|
6
|
+
if RailsNexus.configuration.date_format
|
|
7
|
+
return exception.created_at.strftime(RailsNexus.configuration.date_format)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
time = exception.created_at
|
|
11
|
+
diff = Time.current - time
|
|
12
|
+
|
|
13
|
+
case
|
|
14
|
+
when diff < 60
|
|
15
|
+
"#{(diff).to_i}s ago"
|
|
16
|
+
when diff < 3600
|
|
17
|
+
"#{(diff / 60).to_i}m ago"
|
|
18
|
+
when diff < 86400
|
|
19
|
+
"#{(diff / 3600).to_i}h ago"
|
|
20
|
+
when diff < 604800
|
|
21
|
+
"#{(diff / 86400).to_i}d ago"
|
|
22
|
+
when Date.today == time.to_date
|
|
23
|
+
"Today, #{time.strftime('%I:%M %p')}"
|
|
24
|
+
when Date.yesterday == time.to_date
|
|
25
|
+
"Yesterday, #{time.strftime('%I:%M %p')}"
|
|
26
|
+
else
|
|
27
|
+
time.strftime('%b %d, %Y %I:%M %p')
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def filtered?
|
|
32
|
+
legacy_filters = %i[
|
|
33
|
+
query date_ranges_filter exception_names_filter platform_filter
|
|
34
|
+
controller_actions_filter status_filter
|
|
35
|
+
]
|
|
36
|
+
ransack_filters = params[:q].respond_to?(:to_unsafe_h) ? params[:q].to_unsafe_h.except("s") : {}
|
|
37
|
+
|
|
38
|
+
legacy_filters.any? { |name| params[name].present? } || ransack_filters.values.any?(&:present?)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def listify(text)
|
|
42
|
+
list_items = text.scan(/^\s*\* (.+)/).map { |match| content_tag(:li, match.first) }
|
|
43
|
+
content_tag(:ul, list_items)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def page_title(text)
|
|
47
|
+
title = [RailsNexus.application_name.presence, text].compact.join(" :: ")
|
|
48
|
+
content_for(:title, title)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Rescue textilize call if RedCloth is not available.
|
|
52
|
+
def pretty_format(text)
|
|
53
|
+
begin
|
|
54
|
+
textilize(text).html_safe
|
|
55
|
+
rescue
|
|
56
|
+
simple_format(text).html_safe
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Sort link helper for Ransack-compatible table headers.
|
|
61
|
+
def sort_link(search, attribute, name = nil, **options, &block)
|
|
62
|
+
name ||= attribute.to_s.humanize
|
|
63
|
+
|
|
64
|
+
if defined?(Ransack) && search.respond_to?(:result)
|
|
65
|
+
Ransack::Helpers::FormHelper.instance_method(:sort_link).bind(self).call(search, attribute, name, **options, &block)
|
|
66
|
+
else
|
|
67
|
+
link_to name, "#", **options, &block
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Format backtrace for display with optional collapse
|
|
72
|
+
def format_backtrace(backtrace, limit: nil)
|
|
73
|
+
limit ||= RailsNexus.configuration.backtrace_limit
|
|
74
|
+
lines = backtrace.to_s.split("\n")
|
|
75
|
+
|
|
76
|
+
if lines.length > limit
|
|
77
|
+
collapsed = lines[0...limit]
|
|
78
|
+
remaining = lines[limit..]
|
|
79
|
+
{
|
|
80
|
+
visible: collapsed.join("\n"),
|
|
81
|
+
hidden: remaining.join("\n"),
|
|
82
|
+
hidden_count: remaining.length
|
|
83
|
+
}
|
|
84
|
+
else
|
|
85
|
+
{ visible: lines.join("\n"), hidden: nil, hidden_count: 0 }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Severity badge class based on exception type
|
|
90
|
+
def severity_for(exception)
|
|
91
|
+
case exception.exception_class
|
|
92
|
+
when /Timeout|Slow|Performance/i
|
|
93
|
+
"warning"
|
|
94
|
+
when /404|NotFound|Missing/i
|
|
95
|
+
"info"
|
|
96
|
+
else
|
|
97
|
+
"danger"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Active filter pills for the filter bar
|
|
102
|
+
def active_filter_pills
|
|
103
|
+
pills = []
|
|
104
|
+
|
|
105
|
+
if params[:q].present?
|
|
106
|
+
params[:q].each do |key, value|
|
|
107
|
+
next if value.blank? || key.to_s == "s"
|
|
108
|
+
|
|
109
|
+
labels = {
|
|
110
|
+
"exception_class_cont" => "Exception",
|
|
111
|
+
"message_cont" => "Message",
|
|
112
|
+
"message_or_exception_class_or_controller_name_or_action_name_cont" => "Search",
|
|
113
|
+
"platform_eq" => "Platform",
|
|
114
|
+
"priority_eq" => "Priority",
|
|
115
|
+
"assigned_to_cont" => "Assigned to",
|
|
116
|
+
"fingerprint_cont" => "Fingerprint",
|
|
117
|
+
"occurrence_count_gteq" => "Minimum count"
|
|
118
|
+
}
|
|
119
|
+
label = labels[key.to_s] || key.to_s.sub(/_(cont|eq|gteq|lteq)$/, "").humanize
|
|
120
|
+
pills << { label: label, value: value, group: :q, key: key.to_s }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
if params[:date_ranges_filter].present?
|
|
125
|
+
value = { "1" => "Today", "3" => "Last 3 days", "7" => "Last 7 days", "30" => "Last 30 days" }[params[:date_ranges_filter].to_s] || "Custom"
|
|
126
|
+
pills << { label: "Time", value: value, key: "date_ranges_filter" }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
{
|
|
130
|
+
"controller_actions_filter" => "Source",
|
|
131
|
+
"status_filter" => "Status",
|
|
132
|
+
"query" => "Search"
|
|
133
|
+
}.each do |key, label|
|
|
134
|
+
pills << { label: label, value: params[key], key: key } if params[key].present?
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
pills
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def filter_params_without(pill)
|
|
141
|
+
filters = params_filters.deep_stringify_keys
|
|
142
|
+
|
|
143
|
+
if pill[:group] == :q
|
|
144
|
+
filters["q"]&.delete(pill[:key])
|
|
145
|
+
filters.delete("q") if filters["q"].blank?
|
|
146
|
+
else
|
|
147
|
+
filters.delete(pill[:key])
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
filters
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def exception_occurrence_count(exception)
|
|
154
|
+
exception.respond_to?(:occurrence_count) ? (exception.occurrence_count || 1) : 1
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def exception_status(exception)
|
|
158
|
+
if exception.has_attribute?(:muted) && exception.muted?
|
|
159
|
+
["Muted", "muted"]
|
|
160
|
+
elsif exception.has_attribute?(:snoozed_until) && exception.snoozed?
|
|
161
|
+
["Snoozed", "warning"]
|
|
162
|
+
else
|
|
163
|
+
["Active", "success"]
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def sort_header_class(attribute)
|
|
168
|
+
sort = @q&.sorts&.find { |item| item.name == attribute.to_s }
|
|
169
|
+
["sortable", ("sorted-#{sort.dir}" if sort)].compact.join(" ")
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def exception_searchable?(attribute)
|
|
173
|
+
LoggedException.ransackable_attributes.include?(attribute.to_s)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Simple sparkline data for stats
|
|
177
|
+
def exception_trend_data(days: 7)
|
|
178
|
+
data = (0...days).reverse_each.map do |i|
|
|
179
|
+
date = i.days.ago.to_date
|
|
180
|
+
LoggedException.where(created_at: date.beginning_of_day..date.end_of_day).count
|
|
181
|
+
end
|
|
182
|
+
data
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# ─── Source Code Reading ──────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
# Read source code snippet around a file:line
|
|
188
|
+
def read_source_snippet(file_path, line_number, context: 5)
|
|
189
|
+
return nil unless file_path.present? && line_number.present?
|
|
190
|
+
return nil unless File.exist?(file_path)
|
|
191
|
+
|
|
192
|
+
lines = File.readlines(file_path)
|
|
193
|
+
start_line = [line_number - context, 0].max
|
|
194
|
+
end_line = [line_number + context, lines.length - 1].min
|
|
195
|
+
|
|
196
|
+
snippet_lines = (start_line..end_line).map do |i|
|
|
197
|
+
{
|
|
198
|
+
number: i + 1,
|
|
199
|
+
content: lines[i]&.rstrip,
|
|
200
|
+
highlighted: (i + 1) == line_number
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
{ lines: snippet_lines, start: start_line + 1, end: end_line + 1 }
|
|
205
|
+
rescue StandardError
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Get git blame for a file:line range
|
|
210
|
+
def git_blame_for_line(file_path, line_number, context: 2)
|
|
211
|
+
return nil unless file_path.present? && line_number.present?
|
|
212
|
+
return nil unless File.exist?(file_path)
|
|
213
|
+
|
|
214
|
+
start_line = [line_number - context, 1].max
|
|
215
|
+
end_line = line_number + context
|
|
216
|
+
|
|
217
|
+
result = `git blame -L #{start_line},#{end_line} --porcelain #{file_path} 2>/dev/null`
|
|
218
|
+
return nil unless $?.success?
|
|
219
|
+
|
|
220
|
+
blame_data = {}
|
|
221
|
+
current_sha = nil
|
|
222
|
+
|
|
223
|
+
result.each_line do |line|
|
|
224
|
+
if line =~ /^([0-9a-f]{40})\s+(\d+)\s+(\d+)/
|
|
225
|
+
current_sha = $1
|
|
226
|
+
line_num = $2.to_i
|
|
227
|
+
blame_data[line_num] = { sha: current_sha&.slice(0, 7) } if line_num == line_number
|
|
228
|
+
elsif line =~ /^author\s+(.+)/
|
|
229
|
+
blame_data.each_value { |v| v[:author] = $1.strip if v[:sha] == current_sha&.slice(0, 7) && v[:author].nil? }
|
|
230
|
+
elsif line =~ /^author-time\s+(\d+)/
|
|
231
|
+
time = Time.at($1.to_i)
|
|
232
|
+
blame_data.each_value { |v| v[:date] = time.strftime("%Y-%m-%d") if v[:sha] == current_sha&.slice(0, 7) && v[:date].nil? }
|
|
233
|
+
elsif line =~ /^summary\s+(.+)/
|
|
234
|
+
blame_data.each_value { |v| v[:message] = $1.strip if v[:sha] == current_sha&.slice(0, 7) && v[:message].nil? }
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
blame_data[line_number]
|
|
239
|
+
rescue StandardError
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Parse backtrace line into file path and line number
|
|
244
|
+
def parse_backtrace_line(line)
|
|
245
|
+
return nil unless line.present?
|
|
246
|
+
# Match formats: "file:line" or "file:line:in `method'"
|
|
247
|
+
if line =~ /^(.+?):(\d+)(?::in `(.+?)')?$/
|
|
248
|
+
{ file: $1, line: $2.to_i, method: $3 }
|
|
249
|
+
else
|
|
250
|
+
nil
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsNexus
|
|
4
|
+
module SourceCodeHelper
|
|
5
|
+
# Parse a backtrace line into components
|
|
6
|
+
# Returns { file: String, line: Integer, method: String, gem: String } or nil
|
|
7
|
+
def parse_backtrace_line(line)
|
|
8
|
+
return nil if line.blank?
|
|
9
|
+
|
|
10
|
+
# Match standard Ruby backtrace: "path/to/file.rb:123:in `method_name'"
|
|
11
|
+
if line =~ %r{\A(.+?):(\d+)(?::in `(.+?)')?\z}
|
|
12
|
+
{
|
|
13
|
+
file: $1,
|
|
14
|
+
line: $2.to_i,
|
|
15
|
+
method: $3
|
|
16
|
+
}
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Read source code snippet around a specific line
|
|
21
|
+
# Returns { lines: Array, file: String, from_line: Integer, to_line: Integer, error_line: Integer }
|
|
22
|
+
def read_source_snippet(file_path, error_line, context_lines: 5)
|
|
23
|
+
return nil unless file_path.present? && File.exist?(file_path)
|
|
24
|
+
|
|
25
|
+
total_lines = File.foreach(file_path).count
|
|
26
|
+
from_line = [error_line - context_lines, 1].max
|
|
27
|
+
to_line = [error_line + context_lines, total_lines].min
|
|
28
|
+
|
|
29
|
+
lines = []
|
|
30
|
+
current_line = 0
|
|
31
|
+
|
|
32
|
+
File.foreach(file_path) do |raw_line|
|
|
33
|
+
current_line += 1
|
|
34
|
+
break if current_line > to_line
|
|
35
|
+
next if current_line < from_line
|
|
36
|
+
|
|
37
|
+
lines << {
|
|
38
|
+
number: current_line,
|
|
39
|
+
content: raw_line.rstrip,
|
|
40
|
+
is_error: current_line == error_line
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
{
|
|
45
|
+
lines: lines,
|
|
46
|
+
file: file_path,
|
|
47
|
+
from_line: from_line,
|
|
48
|
+
to_line: to_line,
|
|
49
|
+
error_line: error_line
|
|
50
|
+
}
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Get git blame for a specific line
|
|
54
|
+
# Returns { sha: String, author: String, date: String, message: String }
|
|
55
|
+
def git_blame_for_line(file_path, line_number)
|
|
56
|
+
return nil unless file_path.present? && line_number.present?
|
|
57
|
+
return nil unless File.exist?(file_path)
|
|
58
|
+
return nil unless system("git", "rev-parse", "--git-dir", File.dirname(file_path), [:out, :err] => File::NULL)
|
|
59
|
+
|
|
60
|
+
# Get blame for single line
|
|
61
|
+
blame_output = `git -C "#{File.dirname(file_path)}" blame -L #{line_number},#{line_number} -p "#{File.basename(file_path)}" 2>/dev/null`
|
|
62
|
+
return nil unless $?.success?
|
|
63
|
+
|
|
64
|
+
# Parse porcelain blame output
|
|
65
|
+
result = {}
|
|
66
|
+
blame_output.each_line do |line|
|
|
67
|
+
case line
|
|
68
|
+
when /^([0-9a-f]{40})\s+(\d+)\s+(\d+)/
|
|
69
|
+
result[:sha] = $1[0..6]
|
|
70
|
+
result[:line] = $3.to_i
|
|
71
|
+
when /^author\s+(.+)/
|
|
72
|
+
result[:author] = $1.strip
|
|
73
|
+
when /^author-time\s+(\d+)/
|
|
74
|
+
result[:date] = Time.at($1.to_i).strftime("%Y-%m-%d")
|
|
75
|
+
when /^summary\s+(.+)/
|
|
76
|
+
result[:message] = $1.strip
|
|
77
|
+
when /^filename\s+(.+)/
|
|
78
|
+
result[:filename] = $1.strip
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
result.presence
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Get source code with git blame for multiple backtrace lines
|
|
86
|
+
# Returns array of { backtrace_line:, parsed:, source:, blame: }
|
|
87
|
+
def source_with_blame(backtrace_lines, limit: 10)
|
|
88
|
+
return [] if backtrace_lines.blank?
|
|
89
|
+
|
|
90
|
+
backtrace_lines.first(limit).filter_map do |line|
|
|
91
|
+
parsed = parse_backtrace_line(line)
|
|
92
|
+
next nil unless parsed
|
|
93
|
+
|
|
94
|
+
source = read_source_snippet(parsed[:file], parsed[:line])
|
|
95
|
+
blame = git_blame_for_line(parsed[:file], parsed[:line])
|
|
96
|
+
|
|
97
|
+
{
|
|
98
|
+
backtrace_line: line,
|
|
99
|
+
parsed: parsed,
|
|
100
|
+
source: source,
|
|
101
|
+
blame: blame,
|
|
102
|
+
is_app_code: parsed[:file]&.start_with?(Rails.root.to_s)
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|