bugsage 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/ARCHITECTURE.md +442 -0
- data/CHANGELOG.md +28 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/CONTRIBUTING.md +301 -0
- data/LICENSE.txt +21 -0
- data/README.md +344 -0
- data/ROADMAP.md +217 -0
- data/SECURITY.md +83 -0
- data/docs/AI.md +167 -0
- data/docs/Configuration.md +168 -0
- data/docs/GettingStarted.md +146 -0
- data/docs/RELEASE_CHECKLIST.md +346 -0
- data/docs/Troubleshooting.md +181 -0
- data/docs/images/BadRequest.png +0 -0
- data/docs/images/BadRequest2.png +0 -0
- data/docs/images/BugSage_Logo.png +0 -0
- data/docs/images/BugSage_Social_Preview.png +0 -0
- data/docs/images/NoMethodError.png +0 -0
- data/docs/images/NoMethodError2.png +0 -0
- data/docs/images/README.md +38 -0
- data/docs/releases/README.md +21 -0
- data/docs/releases/v0.2.0.md +40 -0
- data/exe/bugsage +7 -0
- data/lib/bugsage/ai_analyzer.rb +145 -0
- data/lib/bugsage/ai_chat.rb +388 -0
- data/lib/bugsage/ai_context.rb +69 -0
- data/lib/bugsage/ai_panel.rb +708 -0
- data/lib/bugsage/ai_support.rb +36 -0
- data/lib/bugsage/auto_configurator.rb +97 -0
- data/lib/bugsage/cli.rb +59 -0
- data/lib/bugsage/code_context.rb +81 -0
- data/lib/bugsage/code_patch.rb +147 -0
- data/lib/bugsage/configuration.rb +149 -0
- data/lib/bugsage/console_context.rb +51 -0
- data/lib/bugsage/cursor_client.rb +165 -0
- data/lib/bugsage/dashboard.rb +627 -0
- data/lib/bugsage/editor_links.rb +34 -0
- data/lib/bugsage/error_page.rb +298 -0
- data/lib/bugsage/exception_handler.rb +66 -0
- data/lib/bugsage/exception_support.rb +95 -0
- data/lib/bugsage/exceptions_app.rb +31 -0
- data/lib/bugsage/fix_applicator.rb +107 -0
- data/lib/bugsage/formatter.rb +37 -0
- data/lib/bugsage/http_error_capture.rb +104 -0
- data/lib/bugsage/http_response_error.rb +14 -0
- data/lib/bugsage/inline_console.rb +226 -0
- data/lib/bugsage/installation.rb +94 -0
- data/lib/bugsage/installer.rb +66 -0
- data/lib/bugsage/json_endpoint.rb +34 -0
- data/lib/bugsage/locales/en.yml +439 -0
- data/lib/bugsage/middleware.rb +152 -0
- data/lib/bugsage/openai_client.rb +103 -0
- data/lib/bugsage/page_actions.rb +255 -0
- data/lib/bugsage/railtie.rb +49 -0
- data/lib/bugsage/request_context.rb +56 -0
- data/lib/bugsage/rule.rb +271 -0
- data/lib/bugsage/session_clear.rb +35 -0
- data/lib/bugsage/store.rb +60 -0
- data/lib/bugsage/suggestion.rb +58 -0
- data/lib/bugsage/trace_cleaner.rb +24 -0
- data/lib/bugsage/translations.rb +85 -0
- data/lib/bugsage/version.rb +5 -0
- data/lib/bugsage.rb +65 -0
- data/lib/generators/bugsage/install/install_generator.rb +21 -0
- data/lib/generators/bugsage/install/templates/bugsage.rb +22 -0
- data/scripts/publish-github-release.sh +38 -0
- data/sig/bugsage.rbs +4 -0
- metadata +157 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Bugsage
|
|
7
|
+
class ErrorPage
|
|
8
|
+
def self.render(suggestion, context = {})
|
|
9
|
+
file_path, line_number = CodeContext.extract_location(suggestion.location)
|
|
10
|
+
code_context = CodeContext.render_code_context(file_path, line_number)
|
|
11
|
+
|
|
12
|
+
<<~HTML
|
|
13
|
+
<!DOCTYPE html>
|
|
14
|
+
<html>
|
|
15
|
+
<head>
|
|
16
|
+
<meta charset="utf-8">
|
|
17
|
+
<title>#{CodeContext.escape_html(Bugsage.t("ui.error_page.title", issue: suggestion.issue))}</title>
|
|
18
|
+
<style>
|
|
19
|
+
* { box-sizing: border-box; }
|
|
20
|
+
body {
|
|
21
|
+
background: linear-gradient(135deg, #1e1e2e 0%, #2a2a3e 100%);
|
|
22
|
+
color: #cdd6f4;
|
|
23
|
+
font-family: 'SF Mono', Consolas, 'Courier New', monospace;
|
|
24
|
+
padding: 0;
|
|
25
|
+
margin: 0;
|
|
26
|
+
min-height: 100vh;
|
|
27
|
+
}
|
|
28
|
+
.container {
|
|
29
|
+
max-width: 1000px;
|
|
30
|
+
margin: 0 auto;
|
|
31
|
+
padding: 40px 20px;
|
|
32
|
+
}
|
|
33
|
+
h1 {
|
|
34
|
+
color: #f38ba8;
|
|
35
|
+
font-size: 32px;
|
|
36
|
+
margin: 0 0 24px 0;
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
gap: 12px;
|
|
40
|
+
}
|
|
41
|
+
.header-info {
|
|
42
|
+
background: rgba(63, 63, 95, 0.5);
|
|
43
|
+
border-left: 4px solid #f38ba8;
|
|
44
|
+
padding: 16px;
|
|
45
|
+
border-radius: 4px;
|
|
46
|
+
margin-bottom: 24px;
|
|
47
|
+
}
|
|
48
|
+
.header-info p {
|
|
49
|
+
margin: 8px 0;
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
}
|
|
52
|
+
.code-section {
|
|
53
|
+
background: #313244;
|
|
54
|
+
border-radius: 8px;
|
|
55
|
+
margin: 24px 0;
|
|
56
|
+
overflow: hidden;
|
|
57
|
+
border: 1px solid #45475a;
|
|
58
|
+
}
|
|
59
|
+
.code-header {
|
|
60
|
+
background: #45475a;
|
|
61
|
+
padding: 12px 16px;
|
|
62
|
+
border-bottom: 1px solid #585869;
|
|
63
|
+
color: #a6e3a1;
|
|
64
|
+
font-size: 12px;
|
|
65
|
+
font-weight: bold;
|
|
66
|
+
}
|
|
67
|
+
.code-block {
|
|
68
|
+
background: #1e1e2e;
|
|
69
|
+
padding: 0;
|
|
70
|
+
max-height: 600px;
|
|
71
|
+
overflow-y: auto;
|
|
72
|
+
}
|
|
73
|
+
.code-line {
|
|
74
|
+
display: flex;
|
|
75
|
+
border-bottom: 1px solid #45475a;
|
|
76
|
+
}
|
|
77
|
+
.code-line:last-child {
|
|
78
|
+
border-bottom: none;
|
|
79
|
+
}
|
|
80
|
+
.code-line-number {
|
|
81
|
+
background: #313244;
|
|
82
|
+
color: #6e7086;
|
|
83
|
+
padding: 8px 16px;
|
|
84
|
+
text-align: right;
|
|
85
|
+
min-width: 60px;
|
|
86
|
+
border-right: 1px solid #45475a;
|
|
87
|
+
user-select: none;
|
|
88
|
+
font-size: 12px;
|
|
89
|
+
}
|
|
90
|
+
.code-line-content {
|
|
91
|
+
flex: 1;
|
|
92
|
+
padding: 8px 16px;
|
|
93
|
+
white-space: pre;
|
|
94
|
+
overflow-x: auto;
|
|
95
|
+
font-size: 13px;
|
|
96
|
+
color: #cdd6f4;
|
|
97
|
+
}
|
|
98
|
+
.code-line.error {
|
|
99
|
+
background: rgba(243, 139, 168, 0.1);
|
|
100
|
+
border-left: 3px solid #f38ba8;
|
|
101
|
+
}
|
|
102
|
+
.code-line.error .code-line-number {
|
|
103
|
+
background: rgba(243, 139, 168, 0.2);
|
|
104
|
+
color: #f38ba8;
|
|
105
|
+
font-weight: bold;
|
|
106
|
+
}
|
|
107
|
+
#{InlineConsole.styles}
|
|
108
|
+
#{AiPanel.styles}
|
|
109
|
+
.section {
|
|
110
|
+
margin-top: 24px;
|
|
111
|
+
}
|
|
112
|
+
.label {
|
|
113
|
+
color: #89b4fa;
|
|
114
|
+
text-transform: uppercase;
|
|
115
|
+
font-size: 11px;
|
|
116
|
+
letter-spacing: 2px;
|
|
117
|
+
margin-bottom: 12px;
|
|
118
|
+
font-weight: bold;
|
|
119
|
+
}
|
|
120
|
+
.value {
|
|
121
|
+
background: #313244;
|
|
122
|
+
padding: 12px 16px;
|
|
123
|
+
border-radius: 6px;
|
|
124
|
+
word-break: break-all;
|
|
125
|
+
white-space: pre-wrap;
|
|
126
|
+
border-left: 3px solid #89b4fa;
|
|
127
|
+
}
|
|
128
|
+
.message-box {
|
|
129
|
+
background: rgba(243, 139, 168, 0.1);
|
|
130
|
+
border-left: 4px solid #f38ba8;
|
|
131
|
+
padding: 16px;
|
|
132
|
+
border-radius: 4px;
|
|
133
|
+
margin-bottom: 24px;
|
|
134
|
+
color: #f5c2e7;
|
|
135
|
+
}
|
|
136
|
+
.fixes {
|
|
137
|
+
list-style: none;
|
|
138
|
+
padding: 0;
|
|
139
|
+
margin: 0;
|
|
140
|
+
}
|
|
141
|
+
.fixes li {
|
|
142
|
+
background: rgba(166, 227, 161, 0.1);
|
|
143
|
+
padding: 12px 16px;
|
|
144
|
+
border-radius: 6px;
|
|
145
|
+
margin-bottom: 8px;
|
|
146
|
+
border-left: 3px solid #a6e3a1;
|
|
147
|
+
}
|
|
148
|
+
.fixes li::before {
|
|
149
|
+
content: "✓ ";
|
|
150
|
+
color: #a6e3a1;
|
|
151
|
+
font-weight: bold;
|
|
152
|
+
}
|
|
153
|
+
.confidence {
|
|
154
|
+
display: inline-block;
|
|
155
|
+
background: #a6e3a1;
|
|
156
|
+
color: #1e1e2e;
|
|
157
|
+
padding: 6px 16px;
|
|
158
|
+
border-radius: 20px;
|
|
159
|
+
font-weight: bold;
|
|
160
|
+
font-size: 13px;
|
|
161
|
+
}
|
|
162
|
+
.source-badge {
|
|
163
|
+
display: inline-block;
|
|
164
|
+
background: rgba(137, 180, 250, 0.2);
|
|
165
|
+
color: #89b4fa;
|
|
166
|
+
padding: 4px 12px;
|
|
167
|
+
border-radius: 999px;
|
|
168
|
+
font-size: 12px;
|
|
169
|
+
font-weight: bold;
|
|
170
|
+
margin-top: 8px;
|
|
171
|
+
}
|
|
172
|
+
.ai-notes {
|
|
173
|
+
background: rgba(137, 180, 250, 0.1);
|
|
174
|
+
border-left: 4px solid #89b4fa;
|
|
175
|
+
padding: 16px;
|
|
176
|
+
border-radius: 4px;
|
|
177
|
+
margin: 24px 0;
|
|
178
|
+
color: #cdd6f4;
|
|
179
|
+
white-space: pre-wrap;
|
|
180
|
+
}
|
|
181
|
+
.ai-error {
|
|
182
|
+
background: rgba(250, 179, 135, 0.1);
|
|
183
|
+
border-left: 4px solid #fab387;
|
|
184
|
+
padding: 16px;
|
|
185
|
+
border-radius: 4px;
|
|
186
|
+
margin: 24px 0;
|
|
187
|
+
color: #f9e2af;
|
|
188
|
+
white-space: pre-wrap;
|
|
189
|
+
}
|
|
190
|
+
.row {
|
|
191
|
+
display: grid;
|
|
192
|
+
grid-template-columns: 1fr 1fr;
|
|
193
|
+
gap: 24px;
|
|
194
|
+
margin-bottom: 24px;
|
|
195
|
+
}
|
|
196
|
+
@media (max-width: 768px) {
|
|
197
|
+
.row { grid-template-columns: 1fr; }
|
|
198
|
+
}
|
|
199
|
+
</style>
|
|
200
|
+
</head>
|
|
201
|
+
<body>
|
|
202
|
+
<div class="container">
|
|
203
|
+
<h1>#{CodeContext.escape_html(Bugsage.t("ui.error_page.caught", issue: suggestion.issue))}</h1>
|
|
204
|
+
|
|
205
|
+
<div class="header-info">
|
|
206
|
+
<p><strong>#{CodeContext.escape_html(Bugsage.t("ui.error_page.location_label"))}</strong> \
|
|
207
|
+
#{CodeContext.escape_html(suggestion.location)}</p>
|
|
208
|
+
#{render_source_badge(suggestion)}
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
#{code_context}
|
|
212
|
+
|
|
213
|
+
<div class="message-box">
|
|
214
|
+
<strong>#{CodeContext.escape_html(Bugsage.t("ui.error_page.error_message"))}</strong><br>
|
|
215
|
+
#{CodeContext.escape_html(suggestion.root_cause)}
|
|
216
|
+
</div>
|
|
217
|
+
|
|
218
|
+
#{InlineConsole.render_panel}
|
|
219
|
+
|
|
220
|
+
#{render_request_context(context)}
|
|
221
|
+
|
|
222
|
+
#{AiPanel.render_panel(suggestion: suggestion, include_script: false)}
|
|
223
|
+
|
|
224
|
+
<div class="row">
|
|
225
|
+
<div class="section">
|
|
226
|
+
<div class="label">#{CodeContext.escape_html(Bugsage.t("ui.error_page.suggested_fixes"))}</div>
|
|
227
|
+
<ul class="fixes" id="bugsage-fixes">
|
|
228
|
+
#{suggestion.fixes.map { |f| "<li>#{CodeContext.escape_html(f)}</li>" }.join}
|
|
229
|
+
</ul>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="section">
|
|
232
|
+
<div class="label">#{CodeContext.escape_html(Bugsage.t("ui.error_page.confidence_level"))}</div>
|
|
233
|
+
<div style="padding-top: 12px;">
|
|
234
|
+
<span class="confidence" id="bugsage-confidence">\
|
|
235
|
+
#{CodeContext.escape_html(Bugsage.t("ui.error_page.confidence_value", confidence: suggestion.confidence))}</span>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
</div>
|
|
240
|
+
#{AiPanel.render_script if Bugsage.configuration.ai_configured?}
|
|
241
|
+
</body>
|
|
242
|
+
</html>
|
|
243
|
+
HTML
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def self.render_request_context(context)
|
|
247
|
+
return "" if context.empty?
|
|
248
|
+
|
|
249
|
+
rows = context.map do |label, value|
|
|
250
|
+
"<div class=\"section\"><div class=\"label\">#{CodeContext.escape_html(label)}</div>" \
|
|
251
|
+
"<div class=\"value\">#{CodeContext.escape_html(format_value(value))}</div></div>"
|
|
252
|
+
end.join
|
|
253
|
+
|
|
254
|
+
label = CodeContext.escape_html(Bugsage.t("ui.error_page.rails_request_context"))
|
|
255
|
+
"<div class=\"section\"><div class=\"label\">#{label}</div>#{rows}</div>"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def self.render_source_badge(suggestion)
|
|
259
|
+
return "" unless suggestion.ai_enhanced?
|
|
260
|
+
|
|
261
|
+
badge = CodeContext.escape_html(Bugsage.t("ui.error_page.ai_enhanced_analysis"))
|
|
262
|
+
"<p><span class=\"source-badge\">#{badge}</span></p>"
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def self.render_ai_notes(suggestion)
|
|
266
|
+
return "" if suggestion.ai_notes.to_s.strip.empty?
|
|
267
|
+
|
|
268
|
+
<<~HTML
|
|
269
|
+
<div class="section">
|
|
270
|
+
<div class="label">#{CodeContext.escape_html(Bugsage.t("ui.error_page.ai_notes"))}</div>
|
|
271
|
+
<div class="ai-notes">#{CodeContext.escape_html(suggestion.ai_notes)}</div>
|
|
272
|
+
</div>
|
|
273
|
+
HTML
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def self.render_ai_error(ai_error)
|
|
277
|
+
return "" if ai_error.to_s.strip.empty?
|
|
278
|
+
|
|
279
|
+
<<~HTML
|
|
280
|
+
<div class="section">
|
|
281
|
+
<div class="label">#{CodeContext.escape_html(Bugsage.t("ui.error_page.ai_status"))}</div>
|
|
282
|
+
<div class="ai-error">#{CodeContext.escape_html(Bugsage.t("ui.error_page.ai_error", error: ai_error))}</div>
|
|
283
|
+
</div>
|
|
284
|
+
HTML
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def self.format_value(value)
|
|
288
|
+
case value
|
|
289
|
+
when Hash, Array
|
|
290
|
+
JSON.pretty_generate(value)
|
|
291
|
+
when NilClass
|
|
292
|
+
Bugsage.t("common.not_available")
|
|
293
|
+
else
|
|
294
|
+
value.to_s
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bugsage
|
|
4
|
+
module ExceptionHandler
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def extract(env, exception = nil)
|
|
8
|
+
ExceptionSupport.extract(env, exception)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def render_response(env, exception = nil)
|
|
12
|
+
process(env, exception, render: true)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def store_exception(env, exception = nil)
|
|
16
|
+
process(env, exception, render: false)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def store_http_error(env, status, body)
|
|
20
|
+
config = Bugsage.configuration
|
|
21
|
+
return unless config.enabled? && config.capture_http_errors?
|
|
22
|
+
|
|
23
|
+
exception = HttpErrorCapture.build_exception(env, status, body)
|
|
24
|
+
process(env, exception, render: false)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def process(env, exception, render:)
|
|
28
|
+
config = Bugsage.configuration
|
|
29
|
+
return unless config.enabled?
|
|
30
|
+
|
|
31
|
+
exception = extract(env, exception)
|
|
32
|
+
return unless exception
|
|
33
|
+
|
|
34
|
+
suggestion = Rule.match(exception)
|
|
35
|
+
return unless suggestion
|
|
36
|
+
|
|
37
|
+
context = request_context(env)
|
|
38
|
+
ai_error = nil
|
|
39
|
+
|
|
40
|
+
Store.add(suggestion, context, ai_error: ai_error, exception: exception) if config.capture_errors?
|
|
41
|
+
ConsoleContext.set(exception: exception, context: context) if config.show_inline_console?
|
|
42
|
+
AiContext.set(exception: exception, suggestion: suggestion, context: context) if config.ai_configured?
|
|
43
|
+
|
|
44
|
+
return unless render && config.show_error_page?
|
|
45
|
+
|
|
46
|
+
[status_for(exception), { "Content-Type" => "text/html" }, [ErrorPage.render(suggestion, context)]]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def request_context(env)
|
|
50
|
+
RequestContext.from_env(env)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def status_for(exception)
|
|
54
|
+
if ExceptionSupport.routing_error?(exception) || ExceptionSupport.record_not_found?(exception)
|
|
55
|
+
404
|
|
56
|
+
else
|
|
57
|
+
500
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def bugsage_response?(body)
|
|
62
|
+
content = body.respond_to?(:join) ? body.join : body.to_s
|
|
63
|
+
content.include?("BugSage caught") || content.include?("BugSage Dashboard")
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bugsage
|
|
4
|
+
module ExceptionSupport
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
ENV_KEYS = %w[
|
|
8
|
+
action_dispatch.exception
|
|
9
|
+
action_dispatch.original_exception
|
|
10
|
+
action_dispatch.debug_exception
|
|
11
|
+
rack.exception
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def extract(env, exception = nil)
|
|
15
|
+
candidate = exception || env_values(env).find { |value| value } || request_exception(env)
|
|
16
|
+
unwrap(candidate)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def env_values(env)
|
|
20
|
+
return [] unless env.is_a?(Hash)
|
|
21
|
+
|
|
22
|
+
ENV_KEYS.filter_map { |key| env[key] }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def unwrap(object)
|
|
26
|
+
current = object
|
|
27
|
+
|
|
28
|
+
5.times do
|
|
29
|
+
break if current.nil?
|
|
30
|
+
return current if current.is_a?(Exception)
|
|
31
|
+
|
|
32
|
+
current = next_exception_candidate(current)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
current.is_a?(Exception) ? current : nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def next_exception_candidate(object)
|
|
39
|
+
return object.unwrapped_exception if object.respond_to?(:unwrapped_exception)
|
|
40
|
+
|
|
41
|
+
if object.respond_to?(:exception)
|
|
42
|
+
candidate = object.exception
|
|
43
|
+
return candidate unless candidate.equal?(object)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def wrapper?(object)
|
|
50
|
+
object.class.name&.end_with?("ExceptionWrapper")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def routing_error?(exception)
|
|
54
|
+
matches_class?(exception, "ActionController::RoutingError") ||
|
|
55
|
+
message_matches?(exception, /No route matches/i)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def record_not_found?(exception)
|
|
59
|
+
matches_class?(exception, "ActiveRecord::RecordNotFound")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def matches_class?(exception, class_name)
|
|
63
|
+
exception_ancestor_names(exception).include?(class_name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def matches_any_class?(exception, *class_names)
|
|
67
|
+
exception_ancestor_names(exception).intersect?(class_names)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def message_matches?(exception, pattern)
|
|
71
|
+
exception.message.to_s.match?(pattern)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def exception_class_name(exception)
|
|
75
|
+
exception.class.name.to_s
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the class *and* its ancestor class names so that subclasses of a
|
|
79
|
+
# known exception (e.g. PG::UniqueViolation < PG::Error, or a wrapped
|
|
80
|
+
# ActiveRecord::StatementInvalid) still match their base rule.
|
|
81
|
+
def exception_ancestor_names(exception)
|
|
82
|
+
exception.class.ancestors.filter_map do |ancestor|
|
|
83
|
+
ancestor.name if ancestor.is_a?(Class)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def request_exception(env)
|
|
88
|
+
request = env["action_dispatch.request"]
|
|
89
|
+
return unless request
|
|
90
|
+
return request.exception if request.respond_to?(:exception) && request.exception
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bugsage
|
|
4
|
+
class ExceptionsApp
|
|
5
|
+
def self.call(env)
|
|
6
|
+
new.call(env)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(env)
|
|
10
|
+
config = Bugsage.configuration
|
|
11
|
+
|
|
12
|
+
if config.enabled? && config.show_error_page?
|
|
13
|
+
rendered = ExceptionHandler.render_response(env)
|
|
14
|
+
return rendered if rendered
|
|
15
|
+
elsif config.enabled? && config.capture_errors?
|
|
16
|
+
ExceptionHandler.store_exception(env)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
fallback_response(env)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def fallback_response(env)
|
|
25
|
+
fallback = Bugsage.configuration.fallback_exceptions_app
|
|
26
|
+
return fallback.call(env) if fallback
|
|
27
|
+
|
|
28
|
+
[500, { "Content-Type" => "text/html" }, ["<h1>#{Bugsage.t("ui.exceptions_app.unclassified")}</h1>"]]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Bugsage
|
|
6
|
+
class FixApplicator
|
|
7
|
+
extend JsonEndpoint
|
|
8
|
+
|
|
9
|
+
ENDPOINT = "/bugsage/apply-fix"
|
|
10
|
+
|
|
11
|
+
def self.handle_request(env)
|
|
12
|
+
return not_found unless Bugsage.configuration.enabled?
|
|
13
|
+
return forbidden unless apply_allowed?
|
|
14
|
+
|
|
15
|
+
payload = parse_request_body(env)
|
|
16
|
+
location = payload["location"].to_s
|
|
17
|
+
fix = payload["fix"].to_s.strip
|
|
18
|
+
code_patch = payload["code_patch"]
|
|
19
|
+
code_fix = payload["code_fix"].to_s.strip
|
|
20
|
+
mode = payload["mode"].to_s.strip
|
|
21
|
+
|
|
22
|
+
return json_response(error_response(Bugsage.t("errors.location_required"))) if location.empty?
|
|
23
|
+
if code_patch.nil? && code_fix.empty? && fix.empty?
|
|
24
|
+
return json_response(error_response(Bugsage.t("errors.fix_text_or_patch_required")))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
result = apply(
|
|
28
|
+
location: location,
|
|
29
|
+
fix: fix,
|
|
30
|
+
code_patch: code_patch,
|
|
31
|
+
code_fix: code_fix,
|
|
32
|
+
mode: mode
|
|
33
|
+
)
|
|
34
|
+
json_response(result)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.apply(location:, fix: "", code_patch: nil, code_fix: "", mode: "comment")
|
|
38
|
+
file_path, line_number = CodeContext.extract_location(location)
|
|
39
|
+
return error_response(Bugsage.t("errors.could_not_parse_location")) unless file_path && line_number
|
|
40
|
+
unless File.exist?(file_path)
|
|
41
|
+
return error_response(Bugsage.t("errors.source_file_not_found",
|
|
42
|
+
file_path: file_path))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
lines = File.readlines(file_path, chomp: true)
|
|
46
|
+
if line_number < 1 || line_number > lines.length
|
|
47
|
+
return error_response(Bugsage.t("errors.line_out_of_range",
|
|
48
|
+
line_number: line_number))
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if code_patch
|
|
52
|
+
apply_patch!(lines, code_patch, line_number)
|
|
53
|
+
elsif !code_fix.empty? && mode != "comment"
|
|
54
|
+
patch = CodePatch.from_legacy(code_fix, line_number)
|
|
55
|
+
apply_patch!(lines, patch, line_number)
|
|
56
|
+
else
|
|
57
|
+
apply_comment!(lines, line_number, fix)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
File.write(file_path, "#{lines.join("\n")}\n")
|
|
61
|
+
|
|
62
|
+
editor = EditorLinks.for_location("#{file_path}:#{line_number}")
|
|
63
|
+
{
|
|
64
|
+
ok: true,
|
|
65
|
+
message: Bugsage.t("errors.fix_applied", file_path: file_path, line_number: line_number),
|
|
66
|
+
file_path: editor[:file_path],
|
|
67
|
+
line_number: editor[:line_number],
|
|
68
|
+
editor_links: editor
|
|
69
|
+
}
|
|
70
|
+
rescue Bugsage::Error => e
|
|
71
|
+
error_response(e.message)
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
error_response("#{e.class}: #{e.message}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.apply_patch!(lines, patch_data, _error_line)
|
|
77
|
+
patch = case patch_data
|
|
78
|
+
when CodePatch then patch_data
|
|
79
|
+
when Hash then CodePatch.from_hash(patch_data)
|
|
80
|
+
end
|
|
81
|
+
raise Bugsage::Error, Bugsage.t("errors.ai_code_patch_missing") unless patch
|
|
82
|
+
|
|
83
|
+
raise Bugsage::Error, Bugsage.t("errors.no_code_change_required") if patch.action == "no_change"
|
|
84
|
+
|
|
85
|
+
raise Bugsage::Error, Bugsage.t("errors.suggested_code_already_exists") if patch.duplicates_existing?(lines)
|
|
86
|
+
|
|
87
|
+
patch.apply!(lines)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.apply_comment!(lines, line_number, fix)
|
|
91
|
+
marker = "# BUGSAGE:"
|
|
92
|
+
comment = "#{marker} #{fix}"
|
|
93
|
+
return if line_number > 1 && lines[line_number - 2].to_s.include?(marker)
|
|
94
|
+
|
|
95
|
+
lines.insert(line_number - 1, comment)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.apply_allowed?
|
|
99
|
+
env = Bugsage.configuration.current_environment
|
|
100
|
+
%w[development test].include?(env.to_s)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.forbidden
|
|
104
|
+
json_response(error_response(Bugsage.t("errors.fix_only_in_dev_test")), status: 403)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Bugsage
|
|
6
|
+
class Formatter
|
|
7
|
+
def self.print(suggestion)
|
|
8
|
+
pastel = Pastel.new
|
|
9
|
+
print_header(pastel, Bugsage.t("ui.formatter.analysis"))
|
|
10
|
+
print_section(pastel, Bugsage.t("ui.formatter.issue"), suggestion.issue)
|
|
11
|
+
print_section(pastel, Bugsage.t("ui.formatter.location"), suggestion.location)
|
|
12
|
+
print_section(pastel, Bugsage.t("ui.formatter.root_cause"), suggestion.root_cause)
|
|
13
|
+
print_fixes(pastel, suggestion.fixes)
|
|
14
|
+
print_section(pastel, Bugsage.t("ui.formatter.confidence"),
|
|
15
|
+
Bugsage.t("ui.formatter.confidence_value", confidence: suggestion.confidence))
|
|
16
|
+
print_section(pastel, Bugsage.t("ui.formatter.source"), suggestion.source.to_s) if suggestion.ai_enhanced?
|
|
17
|
+
print_section(pastel, Bugsage.t("ui.formatter.ai_notes"), suggestion.ai_notes) if suggestion.ai_notes
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.print_header(pastel, title)
|
|
21
|
+
puts pastel.bold(title)
|
|
22
|
+
puts
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.print_section(pastel, label, value)
|
|
26
|
+
puts pastel.bold(label)
|
|
27
|
+
puts value
|
|
28
|
+
puts
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.print_fixes(pastel, fixes)
|
|
32
|
+
puts pastel.bold(Bugsage.t("ui.formatter.suggested_fixes"))
|
|
33
|
+
fixes.each { |fix| puts pastel.green("✓ ") + fix }
|
|
34
|
+
puts
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|