rails_error_dashboard 0.1.28 → 0.1.30
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/README.md +50 -6
- data/app/controllers/rails_error_dashboard/errors_controller.rb +22 -0
- data/app/helpers/rails_error_dashboard/application_helper.rb +79 -7
- data/app/helpers/rails_error_dashboard/backtrace_helper.rb +149 -0
- data/app/models/rails_error_dashboard/application.rb +1 -1
- data/app/models/rails_error_dashboard/error_log.rb +44 -16
- data/app/views/layouts/rails_error_dashboard.html.erb +71 -1237
- data/app/views/rails_error_dashboard/errors/_error_row.html.erb +10 -2
- data/app/views/rails_error_dashboard/errors/_source_code.html.erb +76 -0
- data/app/views/rails_error_dashboard/errors/_timeline.html.erb +18 -82
- data/app/views/rails_error_dashboard/errors/_user_errors_table.html.erb +70 -0
- data/app/views/rails_error_dashboard/errors/analytics.html.erb +9 -37
- data/app/views/rails_error_dashboard/errors/correlation.html.erb +11 -37
- data/app/views/rails_error_dashboard/errors/index.html.erb +64 -31
- data/app/views/rails_error_dashboard/errors/overview.html.erb +181 -3
- data/app/views/rails_error_dashboard/errors/platform_comparison.html.erb +2 -1
- data/app/views/rails_error_dashboard/errors/settings/_value_badge.html.erb +286 -0
- data/app/views/rails_error_dashboard/errors/settings.html.erb +146 -480
- data/app/views/rails_error_dashboard/errors/show.html.erb +102 -76
- data/db/migrate/20251223000000_create_rails_error_dashboard_complete_schema.rb +188 -0
- data/db/migrate/20251224000001_create_rails_error_dashboard_error_logs.rb +5 -0
- data/db/migrate/20251224081522_add_better_tracking_to_error_logs.rb +3 -0
- data/db/migrate/20251224101217_add_controller_action_to_error_logs.rb +3 -0
- data/db/migrate/20251225071314_add_optimized_indexes_to_error_logs.rb +4 -0
- data/db/migrate/20251225074653_remove_environment_from_error_logs.rb +3 -0
- data/db/migrate/20251225085859_add_enhanced_metrics_to_error_logs.rb +3 -0
- data/db/migrate/20251225093603_add_similarity_tracking_to_error_logs.rb +3 -0
- data/db/migrate/20251225100236_create_error_occurrences.rb +3 -0
- data/db/migrate/20251225101920_create_cascade_patterns.rb +3 -0
- data/db/migrate/20251225102500_create_error_baselines.rb +3 -0
- data/db/migrate/20251226020000_add_workflow_fields_to_error_logs.rb +3 -0
- data/db/migrate/20251226020100_create_error_comments.rb +3 -0
- data/db/migrate/20251229111223_add_additional_performance_indexes.rb +4 -0
- data/db/migrate/20260106094220_create_rails_error_dashboard_applications.rb +3 -0
- data/db/migrate/20260106094233_add_application_to_error_logs.rb +3 -0
- data/db/migrate/20260106094318_finalize_application_foreign_key.rb +5 -0
- data/lib/generators/rails_error_dashboard/install/install_generator.rb +32 -0
- data/lib/generators/rails_error_dashboard/install/templates/initializer.rb +37 -4
- data/lib/rails_error_dashboard/configuration.rb +160 -3
- data/lib/rails_error_dashboard/configuration_error.rb +24 -0
- data/lib/rails_error_dashboard/engine.rb +17 -0
- data/lib/rails_error_dashboard/helpers/user_model_detector.rb +138 -0
- data/lib/rails_error_dashboard/queries/analytics_stats.rb +1 -2
- data/lib/rails_error_dashboard/queries/dashboard_stats.rb +19 -4
- data/lib/rails_error_dashboard/queries/errors_list.rb +27 -8
- data/lib/rails_error_dashboard/services/error_normalizer.rb +143 -0
- data/lib/rails_error_dashboard/services/git_blame_reader.rb +195 -0
- data/lib/rails_error_dashboard/services/github_link_generator.rb +159 -0
- data/lib/rails_error_dashboard/services/source_code_reader.rb +214 -0
- data/lib/rails_error_dashboard/version.rb +1 -1
- data/lib/rails_error_dashboard.rb +6 -0
- metadata +14 -10
- data/app/assets/stylesheets/rails_error_dashboard/_catppuccin_mocha.scss +0 -107
- data/app/assets/stylesheets/rails_error_dashboard/_components.scss +0 -625
- data/app/assets/stylesheets/rails_error_dashboard/_layout.scss +0 -257
- data/app/assets/stylesheets/rails_error_dashboard/_theme_variables.scss +0 -203
- data/app/assets/stylesheets/rails_error_dashboard/application.css +0 -15
- data/app/assets/stylesheets/rails_error_dashboard/application.css.map +0 -7
- data/app/assets/stylesheets/rails_error_dashboard/application.scss +0 -61
- data/app/views/layouts/rails_error_dashboard/application.html.erb +0 -55
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Smart error message normalization for better error grouping
|
|
6
|
+
#
|
|
7
|
+
# Replaces dynamic values (IDs, UUIDs, timestamps, etc.) with placeholders
|
|
8
|
+
# while preserving semantic meaning. This improves error deduplication accuracy
|
|
9
|
+
# compared to naive "replace all numbers" approach.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# ErrorNormalizer.normalize("User 123 not found")
|
|
13
|
+
# # => "User :id not found"
|
|
14
|
+
#
|
|
15
|
+
# ErrorNormalizer.normalize("Expected 2 arguments, got 5")
|
|
16
|
+
# # => "Expected 2 arguments, got 5" (preserves meaningful numbers)
|
|
17
|
+
#
|
|
18
|
+
class ErrorNormalizer
|
|
19
|
+
# Patterns for smart normalization
|
|
20
|
+
# Order matters: more specific patterns should come first
|
|
21
|
+
PATTERNS = {
|
|
22
|
+
# UUIDs (e.g., "550e8400-e29b-41d4-a716-446655440000")
|
|
23
|
+
uuid: /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i,
|
|
24
|
+
|
|
25
|
+
# Memory addresses (e.g., "<User:0x00007f8b1a2b3c4d>", "0x00007f8b1a2b3c4d")
|
|
26
|
+
# MUST come before hash_id to match memory addresses first
|
|
27
|
+
memory_address: /#?<[^>]+:0x[0-9a-f]+>/i,
|
|
28
|
+
hex_address: /\b0x[0-9a-f]{8,16}\b/i,
|
|
29
|
+
|
|
30
|
+
# Timestamps (ISO8601 and common formats)
|
|
31
|
+
# Remove timezone offset separately to avoid leaving it behind
|
|
32
|
+
timestamp_iso: /\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?/,
|
|
33
|
+
timestamp_unix: /\btimestamp[:\s]+\d{10,13}\b/i,
|
|
34
|
+
|
|
35
|
+
# Tokens and API keys (long alphanumeric strings)
|
|
36
|
+
# MUST come before large_number to match long tokens first
|
|
37
|
+
token: /\b[a-z0-9]{32,}\b/i,
|
|
38
|
+
|
|
39
|
+
# Object IDs and database IDs (e.g., "User #123", "id: 456", "ID=789")
|
|
40
|
+
# MUST come before hash_id to match specific ID patterns first
|
|
41
|
+
object_id: /(?:#|(?:id|ID)(?:\s*[=:#]\s*|\s+))\d+\b/,
|
|
42
|
+
# Ruby-style object references (e.g., "User:123", "#<User:123>")
|
|
43
|
+
hash_id: /#?<?[A-Z]\w*:\d+>?/,
|
|
44
|
+
|
|
45
|
+
# File paths with dynamic components (but check for UUIDs in path first)
|
|
46
|
+
# More specific pattern: match /tmp/ followed by UUID-like or hash-like segment
|
|
47
|
+
temp_path: %r{/(?:tmp|var/tmp|private/tmp)/(?:[a-z0-9_-]+/)*[a-z0-9_-]+(?:\.[a-z0-9]+)?},
|
|
48
|
+
|
|
49
|
+
# Numbered URL paths - MUST come before large_number
|
|
50
|
+
# Capture the leading slash with the number, and optional trailing slash
|
|
51
|
+
numbered_path: %r{/\d+(?=/|$)}, # e.g., "/api/users/123/posts" → "/api/users:numbered_path/posts"
|
|
52
|
+
|
|
53
|
+
# Email addresses (preserve domain, replace local part)
|
|
54
|
+
email: /\b[\w.+-]+@[\w.-]+\.[a-z]{2,}\b/i,
|
|
55
|
+
|
|
56
|
+
# IP addresses
|
|
57
|
+
ipv4: /\b(?:\d{1,3}\.){3}\d{1,3}\b/,
|
|
58
|
+
ipv6: /\b(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}\b/i,
|
|
59
|
+
|
|
60
|
+
# Hexadecimal values (but not in memory addresses - already handled)
|
|
61
|
+
hex_value: /\b0x[0-9a-f]+\b/i,
|
|
62
|
+
|
|
63
|
+
# Standalone large numbers (likely IDs, but preserve small numbers < 1000)
|
|
64
|
+
# MUST come last to avoid matching parts of other patterns
|
|
65
|
+
large_number: /\b\d{4,}\b/
|
|
66
|
+
}.freeze
|
|
67
|
+
|
|
68
|
+
class << self
|
|
69
|
+
# Normalize an error message by replacing dynamic values with placeholders
|
|
70
|
+
#
|
|
71
|
+
# @param message [String] the error message to normalize
|
|
72
|
+
# @return [String] the normalized message
|
|
73
|
+
def normalize(message)
|
|
74
|
+
return "" if message.nil?
|
|
75
|
+
return message if message.strip.empty? # Preserve whitespace-only strings
|
|
76
|
+
|
|
77
|
+
normalized = message.dup
|
|
78
|
+
|
|
79
|
+
# Apply each pattern in order
|
|
80
|
+
PATTERNS.each do |type, pattern|
|
|
81
|
+
normalized.gsub!(pattern, ":#{type}")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Clean up leftover timezone offsets that weren't caught by timestamp pattern
|
|
85
|
+
normalized.gsub!(/\s+[+-]\d{2}:\d{2}$/, "")
|
|
86
|
+
|
|
87
|
+
normalized
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Extract significant backtrace frames, skipping gem/vendor code
|
|
91
|
+
#
|
|
92
|
+
# @param backtrace [String] the full backtrace string
|
|
93
|
+
# @param count [Integer] number of frames to extract (default: 3)
|
|
94
|
+
# @return [String, nil] the significant frames joined with "|"
|
|
95
|
+
def extract_significant_frames(backtrace, count: 3)
|
|
96
|
+
return nil if backtrace.blank?
|
|
97
|
+
|
|
98
|
+
frames = backtrace.split("\n")
|
|
99
|
+
.map(&:strip)
|
|
100
|
+
.reject { |line| gem_or_vendor_code?(line) }
|
|
101
|
+
.reject { |line| ruby_stdlib_code?(line) }
|
|
102
|
+
.first(count)
|
|
103
|
+
.map { |line| extract_file_and_method(line) }
|
|
104
|
+
.compact
|
|
105
|
+
|
|
106
|
+
frames.empty? ? nil : frames.join("|")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
# Check if a backtrace line is from gem/vendor code
|
|
112
|
+
def gem_or_vendor_code?(line)
|
|
113
|
+
line.include?("vendor/bundle") ||
|
|
114
|
+
line.include?("gems/") ||
|
|
115
|
+
line.include?(".gem/ruby")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Check if a backtrace line is from Ruby standard library
|
|
119
|
+
def ruby_stdlib_code?(line)
|
|
120
|
+
line.include?("/ruby/") ||
|
|
121
|
+
line.include?("/lib/ruby/") ||
|
|
122
|
+
line.match?(%r{ruby-\d+\.\d+\.\d+/lib})
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Extract file path and method name from a backtrace line
|
|
126
|
+
# Example: "/app/models/user.rb:10:in `name'" => "/app/models/user.rb:name"
|
|
127
|
+
def extract_file_and_method(line)
|
|
128
|
+
# Match pattern: file.rb:line:in `method'
|
|
129
|
+
match = line.match(%r{^(.+\.rb):(\d+)(?::in `(.+)')?})
|
|
130
|
+
return nil unless match
|
|
131
|
+
|
|
132
|
+
file = match[1]
|
|
133
|
+
method = match[3]
|
|
134
|
+
|
|
135
|
+
# Remove absolute path prefix for consistency
|
|
136
|
+
file = file.sub(%r{.*/(?=app/)}, "")
|
|
137
|
+
|
|
138
|
+
method ? "#{file}:#{method}" : file
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module RailsErrorDashboard
|
|
7
|
+
module Services
|
|
8
|
+
# Reads git blame information for specific file lines
|
|
9
|
+
# Executes git blame command and parses porcelain output
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# reader = GitBlameReader.new("/path/to/file.rb", 42)
|
|
13
|
+
# blame = reader.read_blame
|
|
14
|
+
# # => { author: "John Doe", email: "john@example.com", date: Time, sha: "abc123", line: "code" }
|
|
15
|
+
class GitBlameReader
|
|
16
|
+
COMMAND_TIMEOUT = 5 # seconds
|
|
17
|
+
PORCELAIN_FIELDS = %w[
|
|
18
|
+
author
|
|
19
|
+
author-mail
|
|
20
|
+
author-time
|
|
21
|
+
author-tz
|
|
22
|
+
committer
|
|
23
|
+
committer-mail
|
|
24
|
+
committer-time
|
|
25
|
+
committer-tz
|
|
26
|
+
summary
|
|
27
|
+
filename
|
|
28
|
+
].freeze
|
|
29
|
+
|
|
30
|
+
attr_reader :file_path, :line_number, :error
|
|
31
|
+
|
|
32
|
+
# Initialize a new git blame reader
|
|
33
|
+
#
|
|
34
|
+
# @param file_path [String] Path to the source file
|
|
35
|
+
# @param line_number [Integer] Target line number
|
|
36
|
+
def initialize(file_path, line_number)
|
|
37
|
+
@file_path = file_path
|
|
38
|
+
@line_number = line_number.to_i
|
|
39
|
+
@error = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Read git blame information for the target line
|
|
43
|
+
#
|
|
44
|
+
# @return [Hash, nil] Blame data hash or nil if unavailable
|
|
45
|
+
def read_blame
|
|
46
|
+
unless git_available?
|
|
47
|
+
@error = "Git not available"
|
|
48
|
+
return nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
unless File.exist?(file_path)
|
|
52
|
+
@error = "File not found"
|
|
53
|
+
return nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Execute git blame command
|
|
57
|
+
output = execute_git_blame
|
|
58
|
+
return nil unless output
|
|
59
|
+
|
|
60
|
+
# Parse porcelain format output
|
|
61
|
+
parse_blame_output(output)
|
|
62
|
+
rescue StandardError => e
|
|
63
|
+
@error = "Error reading git blame: #{e.message}"
|
|
64
|
+
RailsErrorDashboard::Logger.error("GitBlameReader error for #{file_path}:#{line_number} - #{e.message}")
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Check if git is available on the system
|
|
69
|
+
#
|
|
70
|
+
# @return [Boolean]
|
|
71
|
+
def git_available?
|
|
72
|
+
@git_available ||= begin
|
|
73
|
+
_stdout, _stderr, status = Open3.capture3("git", "--version")
|
|
74
|
+
status.success?
|
|
75
|
+
rescue StandardError
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Execute git blame command for the specific line
|
|
83
|
+
#
|
|
84
|
+
# @return [String, nil] Command output or nil if failed
|
|
85
|
+
def execute_git_blame
|
|
86
|
+
# Build command array (prevents command injection)
|
|
87
|
+
cmd = [
|
|
88
|
+
"git",
|
|
89
|
+
"blame",
|
|
90
|
+
"-L", "#{line_number},#{line_number}",
|
|
91
|
+
"--porcelain",
|
|
92
|
+
"--", # Separator
|
|
93
|
+
file_path
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
# Execute with timeout
|
|
97
|
+
stdout, stderr, status = Timeout.timeout(COMMAND_TIMEOUT) do
|
|
98
|
+
Open3.capture3(*cmd, chdir: Rails.root)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
unless status.success?
|
|
102
|
+
@error = "Git blame failed: #{stderr}"
|
|
103
|
+
RailsErrorDashboard::Logger.debug("Git blame failed for #{file_path}:#{line_number} - #{stderr}")
|
|
104
|
+
return nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
stdout
|
|
108
|
+
rescue Timeout::Error
|
|
109
|
+
@error = "Git blame timeout"
|
|
110
|
+
RailsErrorDashboard::Logger.warn("Git blame timeout for #{file_path}:#{line_number}")
|
|
111
|
+
nil
|
|
112
|
+
rescue StandardError => e
|
|
113
|
+
@error = "Git blame execution error: #{e.message}"
|
|
114
|
+
RailsErrorDashboard::Logger.error("Git blame execution error for #{file_path}:#{line_number} - #{e.message}")
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Parse git blame porcelain format output
|
|
119
|
+
#
|
|
120
|
+
# Git blame --porcelain format:
|
|
121
|
+
# <sha> <line_number> <final_line_number> <num_lines>
|
|
122
|
+
# author <author_name>
|
|
123
|
+
# author-mail <<author_email>>
|
|
124
|
+
# author-time <unix_timestamp>
|
|
125
|
+
# author-tz <timezone>
|
|
126
|
+
# committer <committer_name>
|
|
127
|
+
# committer-mail <<committer_email>>
|
|
128
|
+
# committer-time <unix_timestamp>
|
|
129
|
+
# committer-tz <timezone>
|
|
130
|
+
# summary <commit_message_summary>
|
|
131
|
+
# filename <filename>
|
|
132
|
+
# <TAB><line_content>
|
|
133
|
+
#
|
|
134
|
+
# @param output [String] Git blame porcelain output
|
|
135
|
+
# @return [Hash, nil] Parsed blame data
|
|
136
|
+
def parse_blame_output(output)
|
|
137
|
+
return nil if output.blank?
|
|
138
|
+
|
|
139
|
+
lines = output.split("\n")
|
|
140
|
+
return nil if lines.empty?
|
|
141
|
+
|
|
142
|
+
# First line contains commit SHA and line info
|
|
143
|
+
first_line = lines[0]
|
|
144
|
+
match = first_line.match(/^([0-9a-f]+)\s+(\d+)\s+(\d+)/)
|
|
145
|
+
unless match
|
|
146
|
+
@error = "Incomplete git blame data"
|
|
147
|
+
return nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
sha = match[1]
|
|
151
|
+
data = { sha: sha }
|
|
152
|
+
|
|
153
|
+
# Parse subsequent lines
|
|
154
|
+
lines[1..].each do |line|
|
|
155
|
+
# Check for field: value lines
|
|
156
|
+
PORCELAIN_FIELDS.each do |field|
|
|
157
|
+
if line.start_with?("#{field} ")
|
|
158
|
+
value = line.sub("#{field} ", "")
|
|
159
|
+
|
|
160
|
+
case field
|
|
161
|
+
when "author"
|
|
162
|
+
data[:author] = value
|
|
163
|
+
when "author-mail"
|
|
164
|
+
# Remove < and > brackets
|
|
165
|
+
data[:email] = value.gsub(/[<>]/, "")
|
|
166
|
+
when "author-time"
|
|
167
|
+
# Convert Unix timestamp to Time object
|
|
168
|
+
data[:date] = Time.at(value.to_i)
|
|
169
|
+
when "summary"
|
|
170
|
+
data[:commit_message] = value
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Line content starts with tab
|
|
176
|
+
if line.start_with?("\t")
|
|
177
|
+
data[:line] = line.sub("\t", "")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Validate required fields
|
|
182
|
+
if data[:author].present? && data[:sha].present?
|
|
183
|
+
data
|
|
184
|
+
else
|
|
185
|
+
@error = "Incomplete git blame data"
|
|
186
|
+
nil
|
|
187
|
+
end
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
@error = "Error parsing git blame output: #{e.message}"
|
|
190
|
+
RailsErrorDashboard::Logger.error("Git blame parsing error: #{e.message}")
|
|
191
|
+
nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsErrorDashboard
|
|
4
|
+
module Services
|
|
5
|
+
# Generates links to source code on GitHub, GitLab, or Bitbucket
|
|
6
|
+
# Supports commit SHA, branch, and tag references
|
|
7
|
+
#
|
|
8
|
+
# @example
|
|
9
|
+
# generator = GithubLinkGenerator.new(
|
|
10
|
+
# repository_url: "https://github.com/user/repo",
|
|
11
|
+
# file_path: "app/models/user.rb",
|
|
12
|
+
# line_number: 42,
|
|
13
|
+
# commit_sha: "abc123def456"
|
|
14
|
+
# )
|
|
15
|
+
# generator.generate_link
|
|
16
|
+
# # => "https://github.com/user/repo/blob/abc123def456/app/models/user.rb#L42"
|
|
17
|
+
class GithubLinkGenerator
|
|
18
|
+
attr_reader :repository_url, :file_path, :line_number, :commit_sha, :branch, :error
|
|
19
|
+
|
|
20
|
+
# Initialize a new link generator
|
|
21
|
+
#
|
|
22
|
+
# @param repository_url [String] Base repository URL (GitHub, GitLab, Bitbucket)
|
|
23
|
+
# @param file_path [String] Relative path to file from repository root
|
|
24
|
+
# @param line_number [Integer] Line number to link to
|
|
25
|
+
# @param commit_sha [String, nil] Git commit SHA (recommended for accuracy)
|
|
26
|
+
# @param branch [String, nil] Branch name (fallback if no commit SHA)
|
|
27
|
+
def initialize(repository_url:, file_path:, line_number:, commit_sha: nil, branch: nil)
|
|
28
|
+
@repository_url = repository_url
|
|
29
|
+
@file_path = file_path
|
|
30
|
+
@line_number = line_number.to_i
|
|
31
|
+
@commit_sha = commit_sha
|
|
32
|
+
@branch = branch || "main"
|
|
33
|
+
@error = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Generate a link to the source file on the repository host
|
|
37
|
+
#
|
|
38
|
+
# @return [String, nil] URL to the source file or nil if invalid
|
|
39
|
+
def generate_link
|
|
40
|
+
return nil if repository_url.blank? || file_path.blank?
|
|
41
|
+
|
|
42
|
+
# Normalize repository URL
|
|
43
|
+
normalized_repo = normalize_repository_url
|
|
44
|
+
|
|
45
|
+
# Determine reference (commit SHA or branch)
|
|
46
|
+
reference = determine_reference
|
|
47
|
+
|
|
48
|
+
# Generate link based on repository type
|
|
49
|
+
case detect_repository_type
|
|
50
|
+
when :github
|
|
51
|
+
generate_github_link(normalized_repo, reference)
|
|
52
|
+
when :gitlab
|
|
53
|
+
generate_gitlab_link(normalized_repo, reference)
|
|
54
|
+
when :bitbucket
|
|
55
|
+
generate_bitbucket_link(normalized_repo, reference)
|
|
56
|
+
else
|
|
57
|
+
@error = "Unsupported repository type"
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
rescue StandardError => e
|
|
61
|
+
@error = "Error generating link: #{e.message}"
|
|
62
|
+
RailsErrorDashboard::Logger.error("GithubLinkGenerator error for #{repository_url} - #{e.message}")
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# Normalize repository URL (remove .git suffix, trailing slashes, etc.)
|
|
69
|
+
#
|
|
70
|
+
# @return [String]
|
|
71
|
+
def normalize_repository_url
|
|
72
|
+
url = repository_url.strip
|
|
73
|
+
url = url.chomp(".git")
|
|
74
|
+
url = url.chomp("/")
|
|
75
|
+
url
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Detect repository type from URL
|
|
79
|
+
#
|
|
80
|
+
# @return [Symbol] :github, :gitlab, :bitbucket, or :unknown
|
|
81
|
+
def detect_repository_type
|
|
82
|
+
normalized = normalize_repository_url.downcase
|
|
83
|
+
|
|
84
|
+
return :github if normalized.include?("github.com")
|
|
85
|
+
return :gitlab if normalized.include?("gitlab.com") || normalized.include?("gitlab.")
|
|
86
|
+
return :bitbucket if normalized.include?("bitbucket.org") || normalized.include?("bitbucket.")
|
|
87
|
+
|
|
88
|
+
:unknown
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Determine which reference to use (commit SHA or branch)
|
|
92
|
+
#
|
|
93
|
+
# @return [String]
|
|
94
|
+
def determine_reference
|
|
95
|
+
if commit_sha.present?
|
|
96
|
+
commit_sha
|
|
97
|
+
else
|
|
98
|
+
branch
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Normalize file path (remove leading slashes, Rails.root prefix, etc.)
|
|
103
|
+
#
|
|
104
|
+
# @return [String]
|
|
105
|
+
def normalize_file_path
|
|
106
|
+
path = file_path.strip
|
|
107
|
+
|
|
108
|
+
# Remove leading slash
|
|
109
|
+
path = path.sub(%r{^/}, "")
|
|
110
|
+
|
|
111
|
+
# Remove Rails.root or app root prefix if present
|
|
112
|
+
# Handles paths like "/Users/foo/myapp/app/models/user.rb" -> "app/models/user.rb"
|
|
113
|
+
# Match pattern: look for one of the standard Rails directories
|
|
114
|
+
match = path.match(%r{.*/?((?:app|lib|config|db|spec|test)/.*)$})
|
|
115
|
+
if match
|
|
116
|
+
path = match[1]
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
path
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Generate GitHub link
|
|
123
|
+
#
|
|
124
|
+
# Format: https://github.com/user/repo/blob/{ref}/path/to/file.rb#L42
|
|
125
|
+
#
|
|
126
|
+
# @param repo_url [String] Normalized repository URL
|
|
127
|
+
# @param ref [String] Commit SHA or branch name
|
|
128
|
+
# @return [String]
|
|
129
|
+
def generate_github_link(repo_url, ref)
|
|
130
|
+
normalized_path = normalize_file_path
|
|
131
|
+
"#{repo_url}/blob/#{ref}/#{normalized_path}#L#{line_number}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Generate GitLab link
|
|
135
|
+
#
|
|
136
|
+
# Format: https://gitlab.com/user/repo/-/blob/{ref}/path/to/file.rb#L42
|
|
137
|
+
#
|
|
138
|
+
# @param repo_url [String] Normalized repository URL
|
|
139
|
+
# @param ref [String] Commit SHA or branch name
|
|
140
|
+
# @return [String]
|
|
141
|
+
def generate_gitlab_link(repo_url, ref)
|
|
142
|
+
normalized_path = normalize_file_path
|
|
143
|
+
"#{repo_url}/-/blob/#{ref}/#{normalized_path}#L#{line_number}"
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Generate Bitbucket link
|
|
147
|
+
#
|
|
148
|
+
# Format: https://bitbucket.org/user/repo/src/{ref}/path/to/file.rb#lines-42
|
|
149
|
+
#
|
|
150
|
+
# @param repo_url [String] Normalized repository URL
|
|
151
|
+
# @param ref [String] Commit SHA or branch name
|
|
152
|
+
# @return [String]
|
|
153
|
+
def generate_bitbucket_link(repo_url, ref)
|
|
154
|
+
normalized_path = normalize_file_path
|
|
155
|
+
"#{repo_url}/src/#{ref}/#{normalized_path}#lines-#{line_number}"
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|