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,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bugsage
|
|
4
|
+
# Shared helpers for AI provider responses.
|
|
5
|
+
module AiSupport
|
|
6
|
+
class << self
|
|
7
|
+
def extract_json(response)
|
|
8
|
+
stripped = response.to_s.strip
|
|
9
|
+
stripped = stripped.sub(/\A.*?```(?:json)?\s*/im, "").sub(/\s*```.*\z/m, "") if stripped.include?("```")
|
|
10
|
+
|
|
11
|
+
match = stripped.match(/\{.*\}/m)
|
|
12
|
+
match ? match[0] : stripped
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def log_failure(label, error)
|
|
16
|
+
message = "[BugSage] #{label}: #{error.class}: #{error.message}"
|
|
17
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
18
|
+
Rails.logger.warn(message)
|
|
19
|
+
else
|
|
20
|
+
warn(message)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def build_client(config = Bugsage.configuration)
|
|
25
|
+
return config.ai_client if config.ai_client
|
|
26
|
+
|
|
27
|
+
case config.resolved_ai_provider
|
|
28
|
+
when :cursor
|
|
29
|
+
CursorClient.new(config: config)
|
|
30
|
+
else
|
|
31
|
+
OpenAiClient.new(config: config)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bugsage
|
|
4
|
+
module AutoConfigurator
|
|
5
|
+
API_KEY_ENV_VARS = {
|
|
6
|
+
cursor: %w[CURSOR_API_KEY BUGSAGE_CURSOR_API_KEY],
|
|
7
|
+
openai: %w[OPENAI_API_KEY BUGSAGE_OPENAI_API_KEY]
|
|
8
|
+
}.freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def apply!(config = Bugsage.configuration, env: ENV)
|
|
13
|
+
apply_environment_settings!(config, env)
|
|
14
|
+
apply_ai_settings!(config, env)
|
|
15
|
+
config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def apply_environment_settings!(config, env)
|
|
19
|
+
environments = parse_enabled_environments(env["BUGSAGE_ENABLED_ENVIRONMENTS"])
|
|
20
|
+
return if environments.empty?
|
|
21
|
+
|
|
22
|
+
config.enabled_environments = environments
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def apply_ai_settings!(config, env)
|
|
26
|
+
return if config.ai_enabled == false
|
|
27
|
+
|
|
28
|
+
credentials = detect_api_credentials(env)
|
|
29
|
+
return if credentials.nil?
|
|
30
|
+
|
|
31
|
+
config.ai_enabled = true if config.ai_enabled.nil?
|
|
32
|
+
config.ai_provider ||= credentials[:provider]
|
|
33
|
+
|
|
34
|
+
assign_api_key!(config, credentials)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def detect_api_credentials(env = ENV)
|
|
38
|
+
API_KEY_ENV_VARS.each do |provider, names|
|
|
39
|
+
names.each do |name|
|
|
40
|
+
key = env[name].to_s.strip
|
|
41
|
+
next if key.empty?
|
|
42
|
+
|
|
43
|
+
return { provider: normalize_provider(key, provider), key: key, source: name }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def summary(config = Bugsage.configuration, env: ENV)
|
|
51
|
+
credentials = detect_api_credentials(env)
|
|
52
|
+
environments = Array(config.enabled_environments).join(", ")
|
|
53
|
+
|
|
54
|
+
lines = [
|
|
55
|
+
"BugSage is ready to use.",
|
|
56
|
+
"Enabled environments: #{environments}",
|
|
57
|
+
"Error page: #{enabled_label(config.show_error_page?("development"))}",
|
|
58
|
+
"Dashboard (/bugsage): #{enabled_label(config.show_dashboard?("development"))}",
|
|
59
|
+
"Inline console: #{enabled_label(config.show_inline_console?("development"))}"
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
if credentials
|
|
63
|
+
lines << "AI provider: #{config.resolved_ai_provider} (from #{credentials[:source]})"
|
|
64
|
+
lines << "AI suggestions: #{enabled_label(config.ai_enabled?("development"))}"
|
|
65
|
+
else
|
|
66
|
+
lines << "AI suggestions: off (set OPENAI_API_KEY or CURSOR_API_KEY to enable)"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
lines
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def enabled_label(value)
|
|
73
|
+
value ? "enabled" : "disabled"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_enabled_environments(raw)
|
|
77
|
+
return [] if raw.to_s.strip.empty?
|
|
78
|
+
|
|
79
|
+
raw.split(",").map { |name| name.strip.downcase.to_sym }.reject(&:empty?)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def normalize_provider(key, provider)
|
|
83
|
+
return :cursor if key.start_with?("crsr_")
|
|
84
|
+
|
|
85
|
+
provider
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def assign_api_key!(config, credentials)
|
|
89
|
+
case credentials[:provider]
|
|
90
|
+
when :cursor
|
|
91
|
+
config.cursor_api_key ||= credentials[:key]
|
|
92
|
+
else
|
|
93
|
+
config.openai_api_key ||= credentials[:key]
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/bugsage/cli.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Bugsage
|
|
7
|
+
class CLI < Thor
|
|
8
|
+
desc "version", "Print BugSage version"
|
|
9
|
+
def version
|
|
10
|
+
puts "BugSage v#{Bugsage::VERSION}"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
desc "hello", "Sanity check command"
|
|
14
|
+
def hello
|
|
15
|
+
puts "BugSage is alive."
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
desc "install", "Print install steps and generate config/initializers/bugsage.rb"
|
|
19
|
+
option :destination, aliases: "-d", desc: "Rails app root directory"
|
|
20
|
+
option :force, type: :boolean, default: false, desc: "Overwrite an existing initializer"
|
|
21
|
+
option :guide_only, type: :boolean, default: false, desc: "Print installation steps without writing files"
|
|
22
|
+
def install
|
|
23
|
+
if options[:guide_only]
|
|
24
|
+
Installation.print_guide
|
|
25
|
+
return
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
Installation.print_guide
|
|
29
|
+
say ""
|
|
30
|
+
|
|
31
|
+
result = Installer.run(destination: options[:destination] || Dir.pwd, force: options[:force])
|
|
32
|
+
|
|
33
|
+
if result[:created_initializer]
|
|
34
|
+
say "Created #{result[:initializer_path]}", :green
|
|
35
|
+
else
|
|
36
|
+
say "Skipped #{result[:initializer_path]} (already exists). Use --force to overwrite.", :yellow
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
say "\n#{result[:summary].join("\n")}\n"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
desc "explain", "Run a fake exception through the rule engine (demo)"
|
|
43
|
+
def explain
|
|
44
|
+
fake_exception = begin
|
|
45
|
+
nil.foo
|
|
46
|
+
rescue NoMethodError => e
|
|
47
|
+
e
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
suggestion = Rule.match(fake_exception)
|
|
51
|
+
|
|
52
|
+
if suggestion
|
|
53
|
+
Formatter.print(suggestion)
|
|
54
|
+
else
|
|
55
|
+
puts "No matching rule found."
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cgi"
|
|
4
|
+
|
|
5
|
+
module Bugsage
|
|
6
|
+
module CodeContext
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def extract_location(location)
|
|
10
|
+
return [nil, nil] unless location
|
|
11
|
+
|
|
12
|
+
match = location.match(/^(.+):(\d+)/)
|
|
13
|
+
return [match[1], match[2].to_i] if match
|
|
14
|
+
|
|
15
|
+
[location, nil]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render_code_context(file_path, line_number)
|
|
19
|
+
return "" unless file_path && line_number
|
|
20
|
+
|
|
21
|
+
lines = read_file_lines(file_path)
|
|
22
|
+
return "" unless lines
|
|
23
|
+
|
|
24
|
+
context_range = 5
|
|
25
|
+
start_line = [line_number - context_range, 1].max
|
|
26
|
+
end_line = [line_number + context_range, lines.length].min
|
|
27
|
+
|
|
28
|
+
code_html = ""
|
|
29
|
+
(start_line..end_line).each do |num|
|
|
30
|
+
is_error_line = num == line_number
|
|
31
|
+
line_content = lines[num - 1] || ""
|
|
32
|
+
error_class = is_error_line ? " error" : ""
|
|
33
|
+
|
|
34
|
+
code_html += "<div class=\"code-line#{error_class}\">"
|
|
35
|
+
code_html += "<div class=\"code-line-number\">#{num}</div>"
|
|
36
|
+
code_html += "<div class=\"code-line-content\">#{escape_html(line_content)}</div>"
|
|
37
|
+
code_html += "</div>"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
<<~HTML
|
|
41
|
+
<div class="code-section">
|
|
42
|
+
<div class="code-header">#{escape_html(file_path)}</div>
|
|
43
|
+
<div class="code-block">
|
|
44
|
+
#{code_html}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
HTML
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def numbered_source(file_path, line_number, context_range: 20)
|
|
51
|
+
lines = read_file_lines(file_path)
|
|
52
|
+
return nil unless lines
|
|
53
|
+
|
|
54
|
+
start_line = [line_number - context_range, 1].max
|
|
55
|
+
end_line = [line_number + context_range, lines.length].min
|
|
56
|
+
source = (start_line..end_line).map do |num|
|
|
57
|
+
marker = num == line_number ? ">>" : " "
|
|
58
|
+
"#{marker} #{num.to_s.rjust(4)} | #{lines[num - 1]}"
|
|
59
|
+
end.join("\n")
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
start_line: start_line,
|
|
63
|
+
end_line: end_line,
|
|
64
|
+
error_line: line_number,
|
|
65
|
+
source: source
|
|
66
|
+
}
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def read_file_lines(file_path)
|
|
70
|
+
return nil unless file_path && File.exist?(file_path)
|
|
71
|
+
|
|
72
|
+
File.readlines(file_path, chomp: true)
|
|
73
|
+
rescue StandardError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def escape_html(value)
|
|
78
|
+
CGI.escapeHTML(value.to_s)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bugsage
|
|
4
|
+
class CodePatch
|
|
5
|
+
ACTIONS = %w[replace_lines delete_lines insert_before no_change].freeze
|
|
6
|
+
|
|
7
|
+
attr_reader :action, :start_line, :end_line, :replacement
|
|
8
|
+
|
|
9
|
+
def initialize(action:, start_line:, end_line: nil, replacement: "")
|
|
10
|
+
@action = normalize_action(action)
|
|
11
|
+
@start_line = start_line.to_i
|
|
12
|
+
@end_line = (end_line || start_line).to_i
|
|
13
|
+
@replacement = replacement.to_s
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.from_ai(payload, error_line:)
|
|
17
|
+
patch = payload["code_patch"]
|
|
18
|
+
return from_legacy(payload["code_fix"], error_line) if patch.nil? || patch.empty?
|
|
19
|
+
|
|
20
|
+
new(
|
|
21
|
+
action: patch["action"],
|
|
22
|
+
start_line: patch["start_line"] || error_line,
|
|
23
|
+
end_line: patch["end_line"] || patch["start_line"] || error_line,
|
|
24
|
+
replacement: patch["replacement"]
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.from_legacy(code_fix, error_line)
|
|
29
|
+
text = code_fix.to_s.strip
|
|
30
|
+
return nil if text.empty?
|
|
31
|
+
|
|
32
|
+
new(action: "replace_lines", start_line: error_line, end_line: error_line, replacement: text)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.preview_for(patch)
|
|
36
|
+
return nil if patch.nil?
|
|
37
|
+
|
|
38
|
+
instance = patch.is_a?(self) ? patch : from_hash(patch)
|
|
39
|
+
instance&.preview
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.from_hash(hash)
|
|
43
|
+
return nil if hash.nil? || hash.empty?
|
|
44
|
+
|
|
45
|
+
new(
|
|
46
|
+
action: hash[:action] || hash["action"],
|
|
47
|
+
start_line: hash[:start_line] || hash["start_line"],
|
|
48
|
+
end_line: hash[:end_line] || hash["end_line"],
|
|
49
|
+
replacement: hash[:replacement] || hash["replacement"]
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def apply!(lines)
|
|
54
|
+
validate_range!(lines)
|
|
55
|
+
|
|
56
|
+
case action
|
|
57
|
+
when "no_change"
|
|
58
|
+
nil
|
|
59
|
+
when "delete_lines"
|
|
60
|
+
delete_lines!(lines)
|
|
61
|
+
when "insert_before"
|
|
62
|
+
insert_before!(lines)
|
|
63
|
+
else
|
|
64
|
+
replace_lines!(lines)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def preview
|
|
69
|
+
case action
|
|
70
|
+
when "no_change"
|
|
71
|
+
Bugsage.t("code_patch.no_change")
|
|
72
|
+
when "delete_lines"
|
|
73
|
+
if start_line == end_line
|
|
74
|
+
Bugsage.t("code_patch.delete_line", line: start_line)
|
|
75
|
+
else
|
|
76
|
+
Bugsage.t("code_patch.delete_lines", start_line: start_line, end_line: end_line)
|
|
77
|
+
end
|
|
78
|
+
when "insert_before"
|
|
79
|
+
Bugsage.t("code_patch.insert_before", line: start_line, replacement: replacement)
|
|
80
|
+
else
|
|
81
|
+
if start_line == end_line
|
|
82
|
+
Bugsage.t("code_patch.replace_line", line: start_line, replacement: replacement)
|
|
83
|
+
else
|
|
84
|
+
Bugsage.t("code_patch.replace_lines", start_line: start_line, end_line: end_line, replacement: replacement)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def duplicates_existing?(lines)
|
|
90
|
+
return false if %w[delete_lines no_change].include?(action)
|
|
91
|
+
return true if replacement.strip.empty?
|
|
92
|
+
|
|
93
|
+
normalized = normalize_line(replacement)
|
|
94
|
+
lines.any? { |line| normalize_line(line) == normalized }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def to_h
|
|
98
|
+
{
|
|
99
|
+
action: action,
|
|
100
|
+
start_line: start_line,
|
|
101
|
+
end_line: end_line,
|
|
102
|
+
replacement: replacement
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
private
|
|
107
|
+
|
|
108
|
+
def normalize_action(value)
|
|
109
|
+
action = value.to_s.strip
|
|
110
|
+
ACTIONS.include?(action) ? action : "replace_lines"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def validate_range!(lines)
|
|
114
|
+
raise Bugsage::Error, Bugsage.t("code_patch.invalid_line_range") if start_line < 1 || end_line < start_line
|
|
115
|
+
raise Bugsage::Error, Bugsage.t("code_patch.patch_ends_after_file_end") if end_line > lines.length
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def replace_lines!(lines)
|
|
119
|
+
indent = lines[start_line - 1].to_s[/\A(\s*)/, 1] || ""
|
|
120
|
+
delete_lines!(lines)
|
|
121
|
+
return if replacement.strip.empty?
|
|
122
|
+
|
|
123
|
+
lines.insert(start_line - 1, *indented_replacement_lines(indent))
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def insert_before!(lines)
|
|
127
|
+
indent = lines[start_line - 1].to_s[/\A(\s*)/, 1] || ""
|
|
128
|
+
lines.insert(start_line - 1, *indented_replacement_lines(indent))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def delete_lines!(lines)
|
|
132
|
+
lines.slice!(start_line - 1, end_line - start_line + 1)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def replacement_lines
|
|
136
|
+
replacement.lines(chomp: true)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def indented_replacement_lines(indent)
|
|
140
|
+
replacement_lines.map { |line| line.empty? ? line : "#{indent}#{line.lstrip}" }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def normalize_line(line)
|
|
144
|
+
line.to_s.strip.gsub(/\s+/, " ")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bugsage
|
|
4
|
+
class Configuration
|
|
5
|
+
AI_PROVIDERS = %i[openai cursor].freeze
|
|
6
|
+
|
|
7
|
+
attr_accessor :enabled_environments,
|
|
8
|
+
:show_error_page,
|
|
9
|
+
:show_dashboard,
|
|
10
|
+
:show_inline_console,
|
|
11
|
+
:capture_errors,
|
|
12
|
+
:capture_http_errors,
|
|
13
|
+
:ai_enabled,
|
|
14
|
+
:ai_provider,
|
|
15
|
+
:openai_api_key,
|
|
16
|
+
:openai_model,
|
|
17
|
+
:openai_api_base,
|
|
18
|
+
:cursor_api_key,
|
|
19
|
+
:cursor_model,
|
|
20
|
+
:cursor_api_base,
|
|
21
|
+
:ai_timeout,
|
|
22
|
+
:ai_client,
|
|
23
|
+
:fallback_exceptions_app
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@enabled_environments = %i[development test]
|
|
27
|
+
@show_error_page = nil
|
|
28
|
+
@show_dashboard = nil
|
|
29
|
+
@show_inline_console = nil
|
|
30
|
+
@capture_errors = true
|
|
31
|
+
@capture_http_errors = true
|
|
32
|
+
@ai_enabled = nil
|
|
33
|
+
@ai_provider = nil
|
|
34
|
+
@openai_api_key = nil
|
|
35
|
+
@openai_model = "gpt-4o-mini"
|
|
36
|
+
@openai_api_base = "https://api.openai.com/v1"
|
|
37
|
+
@cursor_api_key = nil
|
|
38
|
+
@cursor_model = nil
|
|
39
|
+
@cursor_api_base = "https://api.cursor.com"
|
|
40
|
+
@ai_timeout = 15
|
|
41
|
+
@ai_client = nil
|
|
42
|
+
@fallback_exceptions_app = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enabled?(environment = current_environment)
|
|
46
|
+
environment_names.include?(environment.to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def show_error_page?(environment = current_environment)
|
|
50
|
+
return show_error_page unless show_error_page.nil?
|
|
51
|
+
|
|
52
|
+
environment.to_s == "development"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def show_dashboard?(environment = current_environment)
|
|
56
|
+
return show_dashboard unless show_dashboard.nil?
|
|
57
|
+
|
|
58
|
+
environment.to_s == "development"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def show_inline_console?(environment = current_environment)
|
|
62
|
+
return show_inline_console unless show_inline_console.nil?
|
|
63
|
+
|
|
64
|
+
environment.to_s == "development"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def capture_errors?(environment = current_environment)
|
|
68
|
+
return false unless enabled?(environment)
|
|
69
|
+
|
|
70
|
+
capture_errors
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def capture_http_errors?(environment = current_environment)
|
|
74
|
+
return false unless capture_errors?(environment)
|
|
75
|
+
|
|
76
|
+
capture_http_errors != false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ai_enabled?(environment = current_environment)
|
|
80
|
+
return false unless enabled?(environment)
|
|
81
|
+
return ai_enabled unless ai_enabled.nil?
|
|
82
|
+
|
|
83
|
+
credential_available?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def credential_available?
|
|
87
|
+
!resolved_openai_api_key.to_s.strip.empty? || !resolved_cursor_api_key.to_s.strip.empty?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ai_configured?(environment = current_environment)
|
|
91
|
+
return false unless ai_enabled?(environment)
|
|
92
|
+
|
|
93
|
+
return true unless ai_client.nil?
|
|
94
|
+
|
|
95
|
+
case resolved_ai_provider
|
|
96
|
+
when :cursor
|
|
97
|
+
!resolved_cursor_api_key.to_s.strip.empty?
|
|
98
|
+
else
|
|
99
|
+
!resolved_openai_api_key.to_s.strip.empty?
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def resolved_ai_provider
|
|
104
|
+
provider = ai_provider&.to_sym
|
|
105
|
+
return provider if provider && AI_PROVIDERS.include?(provider)
|
|
106
|
+
|
|
107
|
+
return :cursor if resolved_cursor_api_key.to_s.start_with?("crsr_")
|
|
108
|
+
|
|
109
|
+
:openai
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def resolved_openai_api_key
|
|
113
|
+
key = openai_api_key || ENV["OPENAI_API_KEY"] || ENV.fetch("BUGSAGE_OPENAI_API_KEY", nil)
|
|
114
|
+
return nil if key.to_s.start_with?("crsr_")
|
|
115
|
+
|
|
116
|
+
key
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def resolved_cursor_api_key
|
|
120
|
+
explicit = cursor_api_key || ENV["CURSOR_API_KEY"] || ENV.fetch("BUGSAGE_CURSOR_API_KEY", nil)
|
|
121
|
+
return explicit if explicit.to_s.start_with?("crsr_")
|
|
122
|
+
|
|
123
|
+
misrouted = openai_api_key || ENV["OPENAI_API_KEY"] || ENV.fetch("BUGSAGE_OPENAI_API_KEY", nil)
|
|
124
|
+
return misrouted if misrouted.to_s.start_with?("crsr_")
|
|
125
|
+
|
|
126
|
+
explicit
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def effective_ai_timeout
|
|
130
|
+
return [ai_timeout, 90].max if resolved_ai_provider == :cursor
|
|
131
|
+
|
|
132
|
+
ai_timeout
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def current_environment
|
|
136
|
+
if defined?(Rails) && Rails.respond_to?(:env)
|
|
137
|
+
Rails.env.to_s
|
|
138
|
+
else
|
|
139
|
+
ENV.fetch("RAILS_ENV", ENV.fetch("RACK_ENV", "development"))
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def environment_names
|
|
146
|
+
Array(enabled_environments).map(&:to_s)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Bugsage
|
|
4
|
+
module ConsoleContext
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def set(exception:, context:)
|
|
8
|
+
@exception = exception
|
|
9
|
+
@context = context || {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def load_from_event(event)
|
|
13
|
+
return false unless event
|
|
14
|
+
|
|
15
|
+
klass = safe_constantize(event[:exception_class])
|
|
16
|
+
message = event[:exception_message].to_s
|
|
17
|
+
message = event[:root_cause].to_s if message.empty?
|
|
18
|
+
|
|
19
|
+
set(
|
|
20
|
+
exception: klass.new(message),
|
|
21
|
+
context: event[:context] || {}
|
|
22
|
+
)
|
|
23
|
+
true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def clear!
|
|
27
|
+
@exception = nil
|
|
28
|
+
@context = {}
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def binding_for_eval
|
|
32
|
+
exception = @exception
|
|
33
|
+
request_context = @context || {}
|
|
34
|
+
params = request_context["Request parameters"] || request_context["Path parameters"] || {}
|
|
35
|
+
|
|
36
|
+
binding
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def available?
|
|
40
|
+
!@exception.nil?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def safe_constantize(name)
|
|
44
|
+
return StandardError if name.to_s.strip.empty?
|
|
45
|
+
|
|
46
|
+
Object.const_get(name)
|
|
47
|
+
rescue NameError
|
|
48
|
+
StandardError
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|