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.
Files changed (69) hide show
  1. checksums.yaml +7 -0
  2. data/ARCHITECTURE.md +442 -0
  3. data/CHANGELOG.md +28 -0
  4. data/CODE_OF_CONDUCT.md +10 -0
  5. data/CONTRIBUTING.md +301 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +344 -0
  8. data/ROADMAP.md +217 -0
  9. data/SECURITY.md +83 -0
  10. data/docs/AI.md +167 -0
  11. data/docs/Configuration.md +168 -0
  12. data/docs/GettingStarted.md +146 -0
  13. data/docs/RELEASE_CHECKLIST.md +346 -0
  14. data/docs/Troubleshooting.md +181 -0
  15. data/docs/images/BadRequest.png +0 -0
  16. data/docs/images/BadRequest2.png +0 -0
  17. data/docs/images/BugSage_Logo.png +0 -0
  18. data/docs/images/BugSage_Social_Preview.png +0 -0
  19. data/docs/images/NoMethodError.png +0 -0
  20. data/docs/images/NoMethodError2.png +0 -0
  21. data/docs/images/README.md +38 -0
  22. data/docs/releases/README.md +21 -0
  23. data/docs/releases/v0.2.0.md +40 -0
  24. data/exe/bugsage +7 -0
  25. data/lib/bugsage/ai_analyzer.rb +145 -0
  26. data/lib/bugsage/ai_chat.rb +388 -0
  27. data/lib/bugsage/ai_context.rb +69 -0
  28. data/lib/bugsage/ai_panel.rb +708 -0
  29. data/lib/bugsage/ai_support.rb +36 -0
  30. data/lib/bugsage/auto_configurator.rb +97 -0
  31. data/lib/bugsage/cli.rb +59 -0
  32. data/lib/bugsage/code_context.rb +81 -0
  33. data/lib/bugsage/code_patch.rb +147 -0
  34. data/lib/bugsage/configuration.rb +149 -0
  35. data/lib/bugsage/console_context.rb +51 -0
  36. data/lib/bugsage/cursor_client.rb +165 -0
  37. data/lib/bugsage/dashboard.rb +627 -0
  38. data/lib/bugsage/editor_links.rb +34 -0
  39. data/lib/bugsage/error_page.rb +298 -0
  40. data/lib/bugsage/exception_handler.rb +66 -0
  41. data/lib/bugsage/exception_support.rb +95 -0
  42. data/lib/bugsage/exceptions_app.rb +31 -0
  43. data/lib/bugsage/fix_applicator.rb +107 -0
  44. data/lib/bugsage/formatter.rb +37 -0
  45. data/lib/bugsage/http_error_capture.rb +104 -0
  46. data/lib/bugsage/http_response_error.rb +14 -0
  47. data/lib/bugsage/inline_console.rb +226 -0
  48. data/lib/bugsage/installation.rb +94 -0
  49. data/lib/bugsage/installer.rb +66 -0
  50. data/lib/bugsage/json_endpoint.rb +34 -0
  51. data/lib/bugsage/locales/en.yml +439 -0
  52. data/lib/bugsage/middleware.rb +152 -0
  53. data/lib/bugsage/openai_client.rb +103 -0
  54. data/lib/bugsage/page_actions.rb +255 -0
  55. data/lib/bugsage/railtie.rb +49 -0
  56. data/lib/bugsage/request_context.rb +56 -0
  57. data/lib/bugsage/rule.rb +271 -0
  58. data/lib/bugsage/session_clear.rb +35 -0
  59. data/lib/bugsage/store.rb +60 -0
  60. data/lib/bugsage/suggestion.rb +58 -0
  61. data/lib/bugsage/trace_cleaner.rb +24 -0
  62. data/lib/bugsage/translations.rb +85 -0
  63. data/lib/bugsage/version.rb +5 -0
  64. data/lib/bugsage.rb +65 -0
  65. data/lib/generators/bugsage/install/install_generator.rb +21 -0
  66. data/lib/generators/bugsage/install/templates/bugsage.rb +22 -0
  67. data/scripts/publish-github-release.sh +38 -0
  68. data/sig/bugsage.rbs +4 -0
  69. 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