diff --git a/Gemfile b/Gemfile index 76e14d08..5f1bbf81 100644 --- a/Gemfile +++ b/Gemfile @@ -63,6 +63,9 @@ gem "jwt", "~> 2.8" # Slack notifications gem "slack-notifier" +# AI provider abstraction +gem "ruby_llm" + # Markdown rendering gem "redcarpet" diff --git a/Gemfile.lock b/Gemfile.lock index 6b67f250..ac333b48 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -159,6 +159,7 @@ GEM erubi (1.13.1) et-orbi (1.3.0) tzinfo + event_stream_parser (1.0.0) factory_bot (6.5.5) activesupport (>= 6.1.0) factory_bot_rails (6.5.1) @@ -177,6 +178,8 @@ GEM multipart-post (~> 2.0) faraday-net_http (3.4.1) net-http (>= 0.5.0) + faraday-retry (2.4.0) + faraday (~> 2.0) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -464,6 +467,17 @@ GEM rubocop-performance (>= 1.24) rubocop-rails (>= 2.30) ruby-progressbar (1.13.0) + ruby_llm (1.14.1) + base64 + event_stream_parser (~> 1) + faraday (>= 1.10.0) + faraday-multipart (>= 1) + faraday-net_http (>= 1) + faraday-retry (>= 1) + marcel (~> 1) + ruby_llm-schema (~> 0) + zeitwerk (~> 2) + ruby_llm-schema (0.3.0) securerandom (0.4.1) shoulda-matchers (6.5.0) activesupport (>= 5.2.0) @@ -598,6 +612,7 @@ DEPENDENCIES resend rspec-rails (~> 6.0) rubocop-rails-omakase + ruby_llm shoulda-matchers sidekiq sidekiq-cron diff --git a/app/controllers/ai_provider_configs_controller.rb b/app/controllers/ai_provider_configs_controller.rb new file mode 100644 index 00000000..137b5654 --- /dev/null +++ b/app/controllers/ai_provider_configs_controller.rb @@ -0,0 +1,61 @@ +class AiProviderConfigsController < ApplicationController + layout "admin" + before_action :authenticate_user! + before_action :set_config, only: [:update, :destroy, :activate] + + def models + provider = params[:provider].to_s + unless %w[anthropic openai gemini].include?(provider) + return render json: { fast: [], power: [] } + end + + chat_models = RubyLLM.models.by_provider(provider).select { |m| m.type == "chat" } + sorted = chat_models.sort_by { |m| m.name.downcase } + + render json: { + fast: sorted.map { |m| { value: m.id, label: m.name } }, + power: sorted.map { |m| { value: m.id, label: m.name } } + } + end + + def create + @config = current_account.ai_provider_configs.build(config_params) + + # Auto-activate if no other configs exist yet + @config.active = true unless current_account.ai_provider_configs.active.exists? + + if @config.save + redirect_to account_settings_path, notice: "AI provider added." + else + redirect_to account_settings_path, alert: @config.errors.full_messages.join(", ") + end + end + + def update + if @config.update(config_params) + redirect_to account_settings_path, notice: "AI provider updated." + else + redirect_to account_settings_path, alert: @config.errors.full_messages.join(", ") + end + end + + def destroy + @config.destroy + redirect_to account_settings_path, notice: "AI provider removed." + end + + def activate + @config.activate! + redirect_to account_settings_path, notice: "#{@config.provider.titleize} is now the active AI provider." + end + + private + + def set_config + @config = current_account.ai_provider_configs.find(params[:id]) + end + + def config_params + params.require(:ai_provider_config).permit(:provider, :api_key, :fast_model, :power_model) + end +end diff --git a/app/controllers/api/v1/cli/incidents_controller.rb b/app/controllers/api/v1/cli/incidents_controller.rb index c0882c1c..3ef94b7e 100644 --- a/app/controllers/api/v1/cli/incidents_controller.rb +++ b/app/controllers/api/v1/cli/incidents_controller.rb @@ -87,7 +87,7 @@ def explain else # Generate new AI summary sample_event = issue.events.order(occurred_at: :desc).first - service = AiSummaryService.new(issue: issue, sample_event: sample_event) + service = AiSummaryService.new(account: issue.account, issue: issue, sample_event: sample_event) result = service.call if result[:error] diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 89755b0a..75453b22 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -11,6 +11,7 @@ class ApplicationController < ActionController::Base checkouts subscriptions account_settings + ai_provider_configs settings ].freeze @@ -197,6 +198,7 @@ def user_not_authorized settings account_settings project_settings + ai_provider_configs ].freeze # Super admin viewing mode: block most write operations (allow settings changes) diff --git a/app/controllers/errors_controller.rb b/app/controllers/errors_controller.rb index 39f68bc9..6afde743 100644 --- a/app/controllers/errors_controller.rb +++ b/app/controllers/errors_controller.rb @@ -344,7 +344,7 @@ def show else # First-time attempt only github_client = github_client_for_issue(@issue) - result = AiSummaryService.new(issue: @issue, sample_event: @selected_event, github_client: github_client).call + result = AiSummaryService.new(account: current_account, issue: @issue, sample_event: @selected_event, github_client: github_client).call if result[:summary].present? @issue.update(ai_summary: result[:summary], ai_summary_generated_at: Time.current) else @@ -385,7 +385,7 @@ def regenerate_ai_summary # Force regeneration by clearing the previous summary github_client = github_client_for_issue(@issue) - result = AiSummaryService.new(issue: @issue, sample_event: @selected_event, github_client: github_client).call + result = AiSummaryService.new(account: current_account, issue: @issue, sample_event: @selected_event, github_client: github_client).call if result[:summary].present? @issue.update(ai_summary: result[:summary], ai_summary_generated_at: Time.current) diff --git a/app/controllers/performance_controller.rb b/app/controllers/performance_controller.rb index 45072601..ab98e6b9 100644 --- a/app/controllers/performance_controller.rb +++ b/app/controllers/performance_controller.rb @@ -618,7 +618,7 @@ def action_detail p95_ms: (@p95_response_time&.round(1)) } sample = @selected_event || @events.first - result = AiPerformanceSummaryService.new(target: @target, stats: stats, sample_event: sample).call + result = AiPerformanceSummaryService.new(account: current_account, target: @target, stats: stats, sample_event: sample).call if result[:summary].present? @performance_summary = result[:summary] diff --git a/app/helpers/errors_helper.rb b/app/helpers/errors_helper.rb index 0755034e..373be2a6 100644 --- a/app/helpers/errors_helper.rb +++ b/app/helpers/errors_helper.rb @@ -79,18 +79,19 @@ def normalize_client_frame(frame) raw: raw || "#{file}:#{line}:in `#{method_name}'", in_app: in_app, frame_type: frame_type, - source_context: normalize_source_context(source_context) + source_context: normalize_source_context(source_context, line&.to_i) } end # Normalize source context from client - def normalize_source_context(ctx) + def normalize_source_context(ctx, frame_line = nil) return nil if ctx.blank? lines_before = ctx["lines_before"] || ctx[:lines_before] || [] line_content = ctx["line_content"] || ctx[:line_content] lines_after = ctx["lines_after"] || ctx[:lines_after] || [] - start_line = ctx["start_line"] || ctx[:start_line] + start_line = ctx["start_line"] || ctx[:start_line] || + (frame_line ? frame_line - lines_before.length : 1) return nil if line_content.blank? diff --git a/app/javascript/controllers/ai_provider_form_controller.js b/app/javascript/controllers/ai_provider_form_controller.js new file mode 100644 index 00000000..add63478 --- /dev/null +++ b/app/javascript/controllers/ai_provider_form_controller.js @@ -0,0 +1,186 @@ +import { Controller } from "@hotwired/stimulus" + +const DEFAULTS = { + anthropic: { fast: "claude-haiku-4-5-20251001", power: "claude-sonnet-4-6" }, + openai: { fast: "gpt-4o-mini", power: "gpt-4o" }, + gemini: { fast: "gemini-2.0-flash", power: "gemini-2.5-pro" } +} + +export default class extends Controller { + static targets = ["provider", "fastModel", "powerModel", "fastDropdown", "powerDropdown", "fastSearch", "powerSearch", "fastDisplay", "powerDisplay"] + static values = { modelsUrl: String } + + connect() { + this.closeAll = this.closeAll.bind(this) + document.addEventListener("click", this.closeAll) + this.models = { fast: [], power: [] } + } + + disconnect() { + document.removeEventListener("click", this.closeAll) + } + + async providerChanged() { + const provider = this.providerTarget.value + if (!provider) { + this.models = { fast: [], power: [] } + this.clearDropdown("fast") + this.clearDropdown("power") + return + } + + // Show loading state + this.fastDisplayTarget.textContent = "Loading models..." + this.fastDisplayTarget.classList.add("text-gray-400") + this.powerDisplayTarget.textContent = "Loading models..." + this.powerDisplayTarget.classList.add("text-gray-400") + + try { + const response = await fetch(`${this.modelsUrlValue}?provider=${provider}`) + this.models = await response.json() + this.populateDropdown("fast", this.models.fast, DEFAULTS[provider]?.fast) + this.populateDropdown("power", this.models.power, DEFAULTS[provider]?.power) + } catch (e) { + this.fastDisplayTarget.textContent = "Failed to load models" + this.powerDisplayTarget.textContent = "Failed to load models" + } + } + + populateDropdown(type, models, defaultValue) { + const hiddenInput = type === "fast" ? this.fastModelTarget : this.powerModelTarget + const display = type === "fast" ? this.fastDisplayTarget : this.powerDisplayTarget + const dropdown = type === "fast" ? this.fastDropdownTarget : this.powerDropdownTarget + + if (!hiddenInput.value && defaultValue) { + hiddenInput.value = defaultValue + } + + const selected = hiddenInput.value + const match = models.find(m => m.value === selected) + if (match) { + display.textContent = match.label + display.classList.remove("text-gray-400") + display.classList.add("text-gray-900") + } else if (defaultValue) { + const def = models.find(m => m.value === defaultValue) + if (def) { + hiddenInput.value = def.value + display.textContent = def.label + display.classList.remove("text-gray-400") + display.classList.add("text-gray-900") + } + } else { + display.textContent = "Select model..." + display.classList.add("text-gray-400") + display.classList.remove("text-gray-900") + } + + this.renderOptions(dropdown, models, hiddenInput.value, defaultValue) + } + + clearDropdown(type) { + const display = type === "fast" ? this.fastDisplayTarget : this.powerDisplayTarget + const dropdown = type === "fast" ? this.fastDropdownTarget : this.powerDropdownTarget + const hiddenInput = type === "fast" ? this.fastModelTarget : this.powerModelTarget + + display.textContent = "Select a provider first" + display.classList.add("text-gray-400") + display.classList.remove("text-gray-900") + hiddenInput.value = "" + dropdown.innerHTML = "" + } + + renderOptions(dropdown, models, selectedValue, defaultValue) { + dropdown.innerHTML = models.map(m => ` + + `).join("") + } + + toggleFast(event) { + event.preventDefault() + event.stopPropagation() + if (!this.providerTarget.value) return + this.powerDropdownTarget.parentElement.classList.add("hidden") + this.fastDropdownTarget.parentElement.classList.toggle("hidden") + if (!this.fastDropdownTarget.parentElement.classList.contains("hidden")) { + this.fastSearchTarget.value = "" + this.fastSearchTarget.focus() + this.filterOptions("fast", "") + } + } + + togglePower(event) { + event.preventDefault() + event.stopPropagation() + if (!this.providerTarget.value) return + this.fastDropdownTarget.parentElement.classList.add("hidden") + this.powerDropdownTarget.parentElement.classList.toggle("hidden") + if (!this.powerDropdownTarget.parentElement.classList.contains("hidden")) { + this.powerSearchTarget.value = "" + this.powerSearchTarget.focus() + this.filterOptions("power", "") + } + } + + filterFast() { + this.filterOptions("fast", this.fastSearchTarget.value) + } + + filterPower() { + this.filterOptions("power", this.powerSearchTarget.value) + } + + filterOptions(type, query) { + const dropdown = type === "fast" ? this.fastDropdownTarget : this.powerDropdownTarget + const q = query.toLowerCase() + dropdown.querySelectorAll(".model-option").forEach(el => { + const text = el.textContent.toLowerCase() + el.style.display = text.includes(q) ? "" : "none" + }) + } + + selectOption(event) { + event.preventDefault() + event.stopPropagation() + const btn = event.currentTarget + const value = btn.dataset.value + const dropdownEl = btn.closest("[data-dropdown-wrapper]") + const type = dropdownEl.dataset.dropdownType + + const hiddenInput = type === "fast" ? this.fastModelTarget : this.powerModelTarget + const display = type === "fast" ? this.fastDisplayTarget : this.powerDisplayTarget + const models = this.models[type] || [] + + hiddenInput.value = value + const match = models.find(m => m.value === value) + if (match) { + display.textContent = match.label + display.classList.remove("text-gray-400") + display.classList.add("text-gray-900") + } + + const dropdown = type === "fast" ? this.fastDropdownTarget : this.powerDropdownTarget + dropdown.querySelectorAll(".model-option").forEach(el => { + el.classList.toggle("bg-indigo-50", el.dataset.value === value) + el.classList.toggle("text-indigo-700", el.dataset.value === value) + }) + + dropdownEl.classList.add("hidden") + } + + closeAll(event) { + if (!this.element.contains(event.target)) { + this.fastDropdownTarget.parentElement.classList.add("hidden") + this.powerDropdownTarget.parentElement.classList.add("hidden") + } + } +} diff --git a/app/jobs/ai_summary_job.rb b/app/jobs/ai_summary_job.rb index 85647493..43102635 100644 --- a/app/jobs/ai_summary_job.rb +++ b/app/jobs/ai_summary_job.rb @@ -30,7 +30,7 @@ def perform(issue_id, event_id, project_id) end github_client = build_github_client(project) - ai = AiSummaryService.new(issue: issue, sample_event: event, github_client: github_client).call + ai = AiSummaryService.new(account: account, issue: issue, sample_event: event, github_client: github_client).call if ai[:summary].present? issue.update(ai_summary: ai[:summary], ai_summary_generated_at: Time.current) diff --git a/app/models/account.rb b/app/models/account.rb index 9cf7ec29..dee86a39 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -12,6 +12,7 @@ class Account < ApplicationRecord has_many :users, dependent: :destroy has_many :projects, dependent: :destroy has_many :api_tokens, through: :projects + has_many :ai_provider_configs, dependent: :destroy # Scopes scope :active, -> { where(active: true) } @@ -118,6 +119,16 @@ def active_subscription? active_subscription_record.present? end + # AI provider configuration + # Database config takes precedence; ENV is fallback only + def ai_provider_config + ai_provider_configs.active.first || env_ai_provider_config + end + + def ai_configured? + ai_provider_config.present? + end + # Account-wide Slack notification settings def slack_webhook_url # Priority: ENV variable > account setting @@ -233,6 +244,19 @@ def has_any_stats? private + def env_ai_provider_config + if ENV["ANTHROPIC_API_KEY"].present? + AiProviderConfig.new(provider: "anthropic", api_key: ENV["ANTHROPIC_API_KEY"], + fast_model: "claude-haiku-4-5-20251001", power_model: "claude-sonnet-4-6") + elsif ENV["OPENAI_API_KEY"].present? + AiProviderConfig.new(provider: "openai", api_key: ENV["OPENAI_API_KEY"], + fast_model: "gpt-4o-mini", power_model: "gpt-4o") + elsif ENV["GEMINI_API_KEY"].present? + AiProviderConfig.new(provider: "gemini", api_key: ENV["GEMINI_API_KEY"], + fast_model: "gemini-2.0-flash", power_model: "gemini-2.5-pro") + end + end + def check_stripe_payment_methods # Ensure API key is set Stripe.api_key ||= ENV["STRIPE_SECRET_KEY"] diff --git a/app/models/ai_provider_config.rb b/app/models/ai_provider_config.rb new file mode 100644 index 00000000..78e9919f --- /dev/null +++ b/app/models/ai_provider_config.rb @@ -0,0 +1,17 @@ +class AiProviderConfig < ApplicationRecord + belongs_to :account + + encrypts :api_key + + validates :provider, presence: true + validates :api_key, presence: true + + scope :active, -> { where(active: true) } + + def activate! + transaction do + account.ai_provider_configs.where.not(id: id).update_all(active: false) + update!(active: true) + end + end +end diff --git a/app/services/ai_performance_summary_service.rb b/app/services/ai_performance_summary_service.rb index 7290c72e..3cbb58c1 100644 --- a/app/services/ai_performance_summary_service.rb +++ b/app/services/ai_performance_summary_service.rb @@ -1,22 +1,25 @@ class AiPerformanceSummaryService + include AiProviderChat + SYSTEM_PROMPT = <<~PROMPT You are a senior Rails performance engineer. Analyze the performance target, recent stats, and a sample event. Provide a concise Root Cause Analysis (RCA), concrete optimization steps, and suggested tests/monitoring. Focus on controller action and database usage; include specific ActiveRecord or N+1 guidance when applicable. PROMPT - def initialize(target:, stats:, sample_event: nil) + def initialize(account:, target:, stats:, sample_event: nil) + @account = account @target = target @stats = stats || {} @event = sample_event end def call - return { error: "missing_api_key", message: "ANTHROPIC_API_KEY not configured" } if api_key.blank? + chat = ai_chat(@account) + return { error: "missing_config", message: "No AI provider configured" } unless chat - content = build_content - response = client_completion(content) - { summary: response } + response = chat.with_instructions(SYSTEM_PROMPT).ask(build_content) + { summary: response.content } rescue => e Rails.logger.error("AI perf summary failed: #{e.class}: #{e.message}") { error: "ai_error", message: e.message } @@ -24,40 +27,6 @@ def call private - def api_key - ENV["ANTHROPIC_API_KEY"] - end - - def client_completion(content) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - - body = { - model: "claude-opus-4-20250514", - max_tokens: 2000, - system: SYSTEM_PROMPT, - messages: [ - { role: "user", content: content } - ] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = api_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - raise "Claude error: #{res.code} #{res.body}" unless res.code.to_i.between?(200, 299) - - json = JSON.parse(res.body) - json.dig("content", 0, "text") - end - def build_content parts = [] parts << "Target: #{@target}" diff --git a/app/services/ai_summary_service.rb b/app/services/ai_summary_service.rb index 145a1aba..0ddce42a 100644 --- a/app/services/ai_summary_service.rb +++ b/app/services/ai_summary_service.rb @@ -1,4 +1,6 @@ class AiSummaryService + include AiProviderChat + # Maximum number of files that can be auto-fixed in a single PR (safety limit) # Additional file suggestions will be shown to user for manual review MAX_FILES_PER_FIX = 1 @@ -62,7 +64,8 @@ class AiSummaryService 7. If other files also need changes, add a "## Related Changes" section listing them with brief descriptions - these will be shown as suggestions for the user to fix locally before creating the PR PROMPT - def initialize(issue:, sample_event: nil, github_client: nil) + def initialize(account:, issue:, sample_event: nil, github_client: nil) + @account = account @issue = issue @event = sample_event @github_client = github_client @@ -70,11 +73,12 @@ def initialize(issue:, sample_event: nil, github_client: nil) end def call - return { error: "missing_api_key", message: "ANTHROPIC_API_KEY not configured" } if api_key.blank? + chat = ai_chat(@account) + return { error: "missing_config", message: "No AI provider configured" } unless chat content = build_content - response = client_completion(content) - { summary: response } + response = chat.with_instructions(SYSTEM_PROMPT).ask(content) + { summary: response.content } rescue => e Rails.logger.error("AI summary failed: #{e.class}: #{e.message}") { error: "ai_error", message: e.message } @@ -82,40 +86,6 @@ def call private - def api_key - ENV["ANTHROPIC_API_KEY"] - end - - def client_completion(content) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - - body = { - model: "claude-haiku-4-5-20251001", - max_tokens: 5000, - system: SYSTEM_PROMPT, - messages: [ - { role: "user", content: content } - ] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = api_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - raise "Claude error: #{res.code} #{res.body}" unless res.code.to_i.between?(200, 299) - - json = JSON.parse(res.body) - json.dig("content", 0, "text") - end - def build_content parts = [] parts << "# Error: #{@issue.exception_class}" diff --git a/app/services/concerns/ai_provider_chat.rb b/app/services/concerns/ai_provider_chat.rb new file mode 100644 index 00000000..f320932a --- /dev/null +++ b/app/services/concerns/ai_provider_chat.rb @@ -0,0 +1,25 @@ +module AiProviderChat + extend ActiveSupport::Concern + + private + + def ai_chat(account, model_type: :fast) + config = account.ai_provider_config + return nil unless config + + model = model_type == :power ? config.power_model : config.fast_model + + ctx = RubyLLM.context do |c| + case config.provider + when "anthropic" + c.anthropic_api_key = config.api_key + when "openai" + c.openai_api_key = config.api_key + when "gemini" + c.gemini_api_key = config.api_key + end + end + + ctx.chat(model: model) + end +end diff --git a/app/services/github/branch_name_generator.rb b/app/services/github/branch_name_generator.rb index 22282e29..3a823e0d 100644 --- a/app/services/github/branch_name_generator.rb +++ b/app/services/github/branch_name_generator.rb @@ -3,8 +3,10 @@ module Github # Generates branch names for GitHub PRs class BranchNameGenerator - def initialize(anthropic_key: nil) - @anthropic_key = anthropic_key || ENV["ANTHROPIC_API_KEY"] + include AiProviderChat + + def initialize(account:) + @account = account end def generate(issue, custom_branch_name = nil) @@ -16,8 +18,9 @@ def generate(issue, custom_branch_name = nil) end # Try to generate a meaningful branch name using AI - if @anthropic_key.present? - ai_branch = generate_ai_branch_name(issue) + chat = ai_chat(@account) + if chat + ai_branch = generate_ai_branch_name(chat, issue) if ai_branch.present? Rails.logger.info "[GitHub API] Using AI-generated branch name: #{ai_branch}" return ai_branch @@ -54,7 +57,7 @@ def sanitize_branch_name(name) sanitized end - def generate_ai_branch_name(issue) + def generate_ai_branch_name(chat, issue) prompt = <<~PROMPT Generate a short, descriptive git branch name for fixing this error. @@ -78,11 +81,11 @@ def generate_ai_branch_name(issue) PROMPT begin - response = claude_chat_completion(prompt) - return nil if response.blank? + response = chat.ask(prompt) + return nil if response.content.blank? # Clean up the response - branch = response.strip + branch = response.content.strip .gsub(/^["']|["']$/, "") # Remove quotes .gsub(/\s+/, "-") # Replace spaces with dashes .downcase @@ -134,35 +137,5 @@ def generate_fallback_branch_name(issue) "#{branch}-#{Time.now.strftime('%m%d%H%M')}" end - def claude_chat_completion(prompt) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 30 - - body = { - model: "claude-opus-4-20250514", - max_tokens: 2000, - system: "You are a senior Rails developer helping fix bugs. Be concise and practical.", - messages: [ - { role: "user", content: prompt } - ] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = @anthropic_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - raise "Claude error: #{res.code}" unless res.code.to_i.between?(200, 299) - - json = JSON.parse(res.body) - json.dig("content", 0, "text") - end end end diff --git a/app/services/github/pr_content_generator.rb b/app/services/github/pr_content_generator.rb index eb24c82b..5f3f6793 100644 --- a/app/services/github/pr_content_generator.rb +++ b/app/services/github/pr_content_generator.rb @@ -3,8 +3,10 @@ module Github # Generates PR content (title, body, code fix) using AI or existing summaries class PrContentGenerator - def initialize(anthropic_key: nil) - @anthropic_key = anthropic_key || ENV["ANTHROPIC_API_KEY"] + include AiProviderChat + + def initialize(account:) + @account = account end def generate(issue) @@ -26,7 +28,7 @@ def generate(issue) file_fixes: parsed[:file_fixes], related_changes: parsed[:related_changes] } - elsif @anthropic_key.present? + elsif @account.ai_configured? # Generate fresh AI analysis for the PR ai_result = generate_ai_pr_analysis(issue, sample_event) title = ai_result[:title] || "Fix #{issue.exception_class} in #{issue.controller_action}" @@ -384,13 +386,14 @@ def build_basic_pr_body(issue, sample_event) end def generate_ai_pr_analysis(issue, sample_event) - return {} unless @anthropic_key.present? + chat = ai_chat(@account, model_type: :power) + return {} unless chat prompt = build_pr_prompt(issue, sample_event) begin - response = claude_chat_completion(prompt) - parse_ai_pr_response(response, issue, sample_event) + response = chat.ask(prompt) + parse_ai_pr_response(response.content, issue, sample_event) rescue => e Rails.logger.error "[GitHub PR] AI analysis failed: #{e.message}" {} @@ -468,37 +471,6 @@ def parse_ai_pr_response(response, issue, sample_event) result end - def claude_chat_completion(prompt) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 30 - - body = { - model: "claude-opus-4-20250514", - max_tokens: 2000, - system: "You are a senior Rails developer helping fix bugs. Be concise and practical.", - messages: [ - { role: "user", content: prompt } - ] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = @anthropic_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - raise "Claude error: #{res.code}" unless res.code.to_i.between?(200, 299) - - json = JSON.parse(res.body) - json.dig("content", 0, "text") - end - def validate_method_structure(code) return false if code.blank? diff --git a/app/services/github/pr_service.rb b/app/services/github/pr_service.rb index 1ccefd04..796ee54e 100644 --- a/app/services/github/pr_service.rb +++ b/app/services/github/pr_service.rb @@ -17,7 +17,7 @@ def initialize(project) @project_app_pk = settings["github_app_pk"] @env_app_id = ENV["AR_GH_APP_ID"] @env_app_pk = Github::TokenManager.resolve_env_private_key - @anthropic_key = ENV["ANTHROPIC_API_KEY"] + @account = @project.account # Initialize service dependencies @token_manager = Github::TokenManager.new( @@ -29,8 +29,8 @@ def initialize(project) env_app_id: @env_app_id, env_app_pk: @env_app_pk ) - @branch_name_generator = Github::BranchNameGenerator.new(anthropic_key: @anthropic_key) - @pr_content_generator = Github::PrContentGenerator.new(anthropic_key: @anthropic_key) + @branch_name_generator = Github::BranchNameGenerator.new(account: @account) + @pr_content_generator = Github::PrContentGenerator.new(account: @account) end def create_n_plus_one_fix_pr(sql_fingerprint) @@ -207,7 +207,7 @@ def create_pr_for_issue(issue, custom_branch_name: nil) # Create commit with suggested fix if available # Use SimpleCodeFixApplier for reliable line-based fixes - code_fix_applier = Github::SimpleCodeFixApplier.new(api_client: api_client, anthropic_key: @anthropic_key, source_branch: source_branch) + code_fix_applier = Github::SimpleCodeFixApplier.new(api_client: api_client, account: @account, source_branch: source_branch) commit_result = create_fix_commit(api_client, code_fix_applier, owner, repo, branch, head_sha, issue, code_fix, before_code, pr_body, file_fixes) if commit_result.is_a?(Hash) && commit_result[:error] return { success: false, error: commit_result[:error] } diff --git a/app/services/github/simple_code_fix_applier.rb b/app/services/github/simple_code_fix_applier.rb index dbea18cc..93ea3854 100644 --- a/app/services/github/simple_code_fix_applier.rb +++ b/app/services/github/simple_code_fix_applier.rb @@ -4,9 +4,11 @@ module Github # Simple, reliable code fix applier using line-based replacement # Replaces the complex heuristic-based approach with a straightforward diff-like system class SimpleCodeFixApplier - def initialize(api_client:, anthropic_key: nil, source_branch: nil) + include AiProviderChat + + def initialize(api_client:, account:, source_branch: nil) @api_client = api_client - @anthropic_key = anthropic_key || ENV["ANTHROPIC_API_KEY"] + @account = account @source_branch = source_branch end @@ -517,7 +519,7 @@ def generate_precise_fix(issue, sample_event, error_frame, file_content, existin Rails.logger.info "[SimpleFixApplier] FAST PATH failed, falling back to AI" end - return nil unless @anthropic_key.present? + return nil unless @account.ai_configured? # Build context around error - expand to 30 lines for better understanding lines = file_content.lines @@ -824,7 +826,7 @@ def apply_fix_to_different_file(owner, repo, file_path, issue, existing_fix_code # Generate fix for a file that's different from the error location def generate_fix_for_file(file_path, file_content, issue, existing_fix_code) - return nil unless @anthropic_key.present? + return nil unless @account.ai_configured? lines = file_content.lines # Show first 50 lines for context (usually enough for a model file) @@ -902,34 +904,14 @@ def valid_ruby_syntax?(content) end def claude_completion(prompt) - require "net/http" - require "json" - - uri = URI.parse("https://api.anthropic.com/v1/messages") - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true - http.read_timeout = 30 - - body = { - model: "claude-opus-4-20250514", - max_tokens: 2500, - messages: [{ role: "user", content: prompt }] - } - - req = Net::HTTP::Post.new(uri.request_uri) - req["x-api-key"] = @anthropic_key - req["anthropic-version"] = "2023-06-01" - req["Content-Type"] = "application/json" - req.body = JSON.dump(body) - - res = http.request(req) - unless res.code.to_i.between?(200, 299) - Rails.logger.error "[SimpleFixApplier] Claude API error: #{res.code} - #{res.body.first(200)}" - return nil - end + chat = ai_chat(@account, model_type: :power) + return nil unless chat - json = JSON.parse(res.body) - json.dig("content", 0, "text") + response = chat.ask(prompt) + response.content + rescue => e + Rails.logger.error "[SimpleFixApplier] AI API error: #{e.class}: #{e.message}" + nil end end end diff --git a/app/views/account_settings/show.html.erb b/app/views/account_settings/show.html.erb index e0bb788d..880977de 100644 --- a/app/views/account_settings/show.html.erb +++ b/app/views/account_settings/show.html.erb @@ -194,78 +194,152 @@ - +
-

Your Personal Notification Preferences

-

Configure which notifications you want to receive and where

+
+
+ + + +
+

AI Provider

+

Configure which AI provider powers summaries, code fixes, and PR generation

+
+
+
+ <% if @account.ai_configured? %> + + + + + Active + + <% else %> + + + + + Not Configured + + <% end %> +
+
-
- <% user_preferences = @account.user_notification_preferences(current_user) %> - <%= form_with(url: update_user_preferences_account_settings_path, method: :patch, local: true, class: "space-y-4") do |form| %> - -
- - <%= form.text_field :personal_channel, - value: user_preferences['personal_channel'], - placeholder: "Leave empty to use account default (#{@account.slack_channel})", - class: "block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm" %> -

- Set a personal channel (e.g., #my-alerts or @username) to receive notifications separately from the team -

+
+ + <% if @account.ai_provider_configs.any? %> +
+ <% @account.ai_provider_configs.order(:created_at).each do |config| %> +
+
+ <% if config.active? %> + + <% else %> + + <% end %> +
+

<%= config.provider.titleize %>

+

+ Fast: <%= config.fast_model || "default" %> · Power: <%= config.power_model || "default" %> +

+
+
+
+ <% unless config.active? %> + <%= button_to "Set Active", + activate_account_settings_ai_provider_config_path(config), + method: :post, + class: "inline-flex items-center px-3 py-1 border border-gray-300 text-xs font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50" %> + <% end %> + <%= button_to "Remove", + account_settings_ai_provider_config_path(config), + method: :delete, + data: { turbo_confirm: "Remove this AI provider?" }, + class: "inline-flex items-center px-3 py-1 border border-red-200 text-xs font-medium rounded-md text-red-700 bg-white hover:bg-red-50" %> +
+
+ <% end %>
+ <% end %> - -
-

Notification Types

+ +
> + + + + + Add AI Provider + -
-
- <%= form.check_box :error_notifications, - checked: user_preferences['error_notifications'], - class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %> - -
+
+ <%= form_with(model: AiProviderConfig.new, url: account_settings_ai_provider_configs_path, method: :post, local: true, class: "space-y-4") do |form| %> +
+
+ + <%= form.select :provider, + [["Anthropic", "anthropic"], ["OpenAI", "openai"], ["Google Gemini", "gemini"]], + { prompt: "Select provider..." }, + { class: "block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", + data: { ai_provider_form_target: "provider", action: "ai-provider-form#providerChanged" } } %> +
-
- <%= form.check_box :performance_notifications, - checked: user_preferences['performance_notifications'], - class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %> - -
+
+ + <%= form.password_field :api_key, + placeholder: "sk-...", + required: true, + class: "block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %> +
-
- <%= form.check_box :n_plus_one_notifications, - checked: user_preferences['n_plus_one_notifications'], - class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %> - -
+
+ + <%= form.hidden_field :fast_model, data: { ai_provider_form_target: "fastModel" } %> + + +

Used for summaries, branch names

+
-
- <%= form.check_box :new_issue_notifications, - checked: user_preferences['new_issue_notifications'], - class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" %> - +
+ + <%= form.hidden_field :power_model, data: { ai_provider_form_target: "powerModel" } %> + + +

Used for code fixes, PR generation

+
-
-
-
- <%= form.submit "Save My Preferences", - class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500" %> +
+ <%= form.submit "Add Provider", + class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> +
+ <% end %>
- <% end %> +
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 8628e9fb..94f3ad9b 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -261,6 +261,15 @@ Settings <% end %> + <%= link_to account_settings_path, + class: "sidebar-nav-item group relative flex items-center px-4 py-3 text-gray-300 rounded-lg hover:bg-gray-800 hover:text-white transition-colors #{'bg-gray-800 text-white' if current_page?(account_settings_path)}" do %> + + + + Account Settings + Account Settings + <% end %> + <%= link_to usage_path, class: "sidebar-nav-item group relative flex items-center px-4 py-3 text-gray-300 rounded-lg hover:bg-gray-800 hover:text-white transition-colors #{'bg-gray-800 text-white' if current_page?(usage_path)}" do %> diff --git a/app/views/performance/action_detail.html.erb b/app/views/performance/action_detail.html.erb index ad62b0d7..c68c21a4 100644 --- a/app/views/performance/action_detail.html.erb +++ b/app/views/performance/action_detail.html.erb @@ -97,8 +97,10 @@
- <% elsif defined?(@ai_result) && @ai_result && @ai_result[:error] == 'missing_api_key' %> -
Set OPENAI_API_KEY to enable AI summaries.
+ <% elsif defined?(@ai_result) && @ai_result && @ai_result[:error] == 'missing_config' %> +
+ <%= link_to "Configure an AI provider", account_settings_path, class: "text-indigo-600 hover:text-indigo-500" %> in Settings to enable AI summaries. +
<% elsif defined?(@ai_result) && @ai_result && @ai_result[:error] %>
AI summary failed: <%= @ai_result[:message] %>
<% else %> diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb new file mode 100644 index 00000000..c5458f66 --- /dev/null +++ b/config/initializers/active_record_encryption.rb @@ -0,0 +1,24 @@ +primary_key = ENV["ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY"] +deterministic_key = ENV["ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY"] +key_derivation_salt = ENV["ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT"] + +if Rails.env.production? + missing = { + "ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY" => primary_key, + "ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY" => deterministic_key, + "ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT" => key_derivation_salt + }.select { |_, v| v.blank? }.keys + + if missing.any? + raise "Missing Active Record encryption ENV vars: #{missing.join(', ')}. " \ + "Generate with `bin/rails db:encryption:init` and set before boot." + end +else + primary_key ||= "development_primary_key_not_for_production_use_32" + deterministic_key ||= "development_deterministic_key_not_for_prod" + key_derivation_salt ||= "development_key_derivation_salt_not_for_prod" +end + +Rails.application.config.active_record.encryption.primary_key = primary_key +Rails.application.config.active_record.encryption.deterministic_key = deterministic_key +Rails.application.config.active_record.encryption.key_derivation_salt = key_derivation_salt diff --git a/config/routes.rb b/config/routes.rb index f099d6ea..d0d70d46 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -72,6 +72,14 @@ resource :account_settings, path: "account/settings", only: [:show, :update] do post :test_notification patch :update_user_preferences + resources :ai_provider_configs, only: [:create, :update, :destroy] do + collection do + get :models + end + member do + post :activate + end + end end # Top-level Performance routes (no /admin or /projects/:id required) diff --git a/db/migrate/20260410201004_create_ai_provider_configs.rb b/db/migrate/20260410201004_create_ai_provider_configs.rb new file mode 100644 index 00000000..6341a4c7 --- /dev/null +++ b/db/migrate/20260410201004_create_ai_provider_configs.rb @@ -0,0 +1,14 @@ +class CreateAiProviderConfigs < ActiveRecord::Migration[8.0] + def change + create_table :ai_provider_configs do |t| + t.references :account, null: false, foreign_key: true + t.string :provider, null: false + t.string :api_key, null: false + t.string :fast_model + t.string :power_model + t.boolean :active, default: false, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 2dea65fb..0e3306b9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_04_02_120000) do +ActiveRecord::Schema[8.0].define(version: 2026_04_10_201004) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -78,6 +78,18 @@ t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true end + create_table "ai_provider_configs", force: :cascade do |t| + t.bigint "account_id", null: false + t.string "provider", null: false + t.string "api_key", null: false + t.string "fast_model" + t.string "power_model" + t.boolean "active", default: false, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_ai_provider_configs_on_account_id" + end + create_table "ai_requests", force: :cascade do |t| t.bigint "account_id", null: false t.bigint "user_id", null: false @@ -777,6 +789,7 @@ add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "ai_provider_configs", "accounts" add_foreign_key "alert_notifications", "accounts" add_foreign_key "alert_notifications", "alert_rules" add_foreign_key "alert_notifications", "projects" diff --git a/spec/factories/ai_provider_configs.rb b/spec/factories/ai_provider_configs.rb new file mode 100644 index 00000000..ed7f6849 --- /dev/null +++ b/spec/factories/ai_provider_configs.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :ai_provider_config do + association :account + provider { "anthropic" } + api_key { "sk-ant-test-#{SecureRandom.hex(16)}" } + fast_model { "claude-haiku-4-5-20251001" } + power_model { "claude-sonnet-4-6" } + active { false } + end +end diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb index b60c4b15..a6c6aa85 100644 --- a/spec/models/account_spec.rb +++ b/spec/models/account_spec.rb @@ -75,6 +75,82 @@ end end + describe '#ai_provider_config' do + it 'returns the active AI provider config' do + account = create(:account) + config = create(:ai_provider_config, account: account, active: true) + + expect(account.ai_provider_config).to eq(config) + end + + it 'returns nil when no active config exists' do + account = create(:account) + + expect(account.ai_provider_config).to be_nil + end + end + + describe '#ai_configured?' do + it 'returns true when an active config exists' do + account = create(:account) + create(:ai_provider_config, account: account, active: true) + + expect(account.ai_configured?).to be true + end + + it 'returns false when no config exists' do + account = create(:account) + + expect(account.ai_configured?).to be false + end + + it 'returns true when ANTHROPIC_API_KEY env is set and no DB config' do + account = create(:account) + + ClimateControl.modify(ANTHROPIC_API_KEY: "sk-ant-test") do + expect(account.ai_configured?).to be true + end + end + + it 'returns true when OPENAI_API_KEY env is set and no DB config' do + account = create(:account) + + ClimateControl.modify(OPENAI_API_KEY: "sk-test") do + expect(account.ai_configured?).to be true + end + end + end + + describe '#ai_provider_config ENV fallback' do + it 'returns ENV-based config when no DB config exists' do + account = create(:account) + + ClimateControl.modify(ANTHROPIC_API_KEY: "sk-ant-test") do + config = account.ai_provider_config + expect(config).to be_present + expect(config.provider).to eq("anthropic") + expect(config.api_key).to eq("sk-ant-test") + end + end + + it 'prefers DB config over ENV' do + account = create(:account) + db_config = create(:ai_provider_config, account: account, provider: "openai", active: true) + + ClimateControl.modify(ANTHROPIC_API_KEY: "sk-ant-test") do + expect(account.ai_provider_config).to eq(db_config) + end + end + + it 'returns nil when neither DB config nor ENV exists' do + account = create(:account) + + ClimateControl.modify(ANTHROPIC_API_KEY: nil, OPENAI_API_KEY: nil, GEMINI_API_KEY: nil) do + expect(account.ai_provider_config).to be_nil + end + end + end + # describe '#slack_channel=' do # it 'normalizes channel to start with #' do # account.slack_channel = 'alerts' diff --git a/spec/models/ai_provider_config_spec.rb b/spec/models/ai_provider_config_spec.rb new file mode 100644 index 00000000..d735b3f0 --- /dev/null +++ b/spec/models/ai_provider_config_spec.rb @@ -0,0 +1,47 @@ +require 'rails_helper' + +RSpec.describe AiProviderConfig, type: :model do + subject(:config) { build(:ai_provider_config) } + + describe 'validations' do + it { is_expected.to validate_presence_of(:provider) } + it { is_expected.to validate_presence_of(:api_key) } + end + + describe 'associations' do + it { is_expected.to belong_to(:account) } + end + + describe '#activate!' do + let(:account) { create(:account) } + let!(:config_a) { create(:ai_provider_config, account: account, provider: "anthropic", active: true) } + let!(:config_b) { create(:ai_provider_config, account: account, provider: "openai", active: false) } + + it 'sets self as active and deactivates siblings' do + config_b.activate! + + expect(config_b.reload).to be_active + expect(config_a.reload).not_to be_active + end + + it 'only deactivates configs for the same account' do + other_account = create(:account) + other_config = create(:ai_provider_config, account: other_account, provider: "anthropic", active: true) + + config_b.activate! + + expect(other_config.reload).to be_active + end + end + + describe 'scopes' do + let(:account) { create(:account) } + + it '.active returns only active configs' do + active = create(:ai_provider_config, account: account, active: true) + _inactive = create(:ai_provider_config, account: account, provider: "openai", active: false) + + expect(account.ai_provider_configs.active).to eq([active]) + end + end +end diff --git a/spec/requests/ai_provider_configs_spec.rb b/spec/requests/ai_provider_configs_spec.rb new file mode 100644 index 00000000..21712122 --- /dev/null +++ b/spec/requests/ai_provider_configs_spec.rb @@ -0,0 +1,78 @@ +require 'rails_helper' + +RSpec.describe "AiProviderConfigs", type: :request do + let(:account) { create(:account, :team_plan) } + let(:user) { create(:user, :confirmed, :owner, account: account) } + + before do + ActsAsTenant.current_tenant = account + login_as user, scope: :user + end + + describe "POST /account/settings/ai_provider_configs" do + let(:valid_params) do + { + ai_provider_config: { + provider: "anthropic", + api_key: "sk-ant-test-key", + fast_model: "claude-haiku-4-5-20251001", + power_model: "claude-sonnet-4-6" + } + } + end + + it "creates a new provider config" do + expect { + post account_settings_ai_provider_configs_path, params: valid_params + }.to change(AiProviderConfig, :count).by(1) + end + + it "redirects to account settings with notice" do + post account_settings_ai_provider_configs_path, params: valid_params + expect(response).to redirect_to(account_settings_path) + end + + it "auto-activates the first config" do + post account_settings_ai_provider_configs_path, params: valid_params + expect(account.ai_provider_configs.last).to be_active + end + end + + describe "PATCH /account/settings/ai_provider_configs/:id" do + let!(:config) { create(:ai_provider_config, account: account, active: true) } + + it "updates the provider config" do + patch account_settings_ai_provider_config_path(config), params: { + ai_provider_config: { fast_model: "gpt-4o-mini" } + } + expect(config.reload.fast_model).to eq("gpt-4o-mini") + end + end + + describe "DELETE /account/settings/ai_provider_configs/:id" do + let!(:config) { create(:ai_provider_config, account: account) } + + it "destroys the provider config" do + expect { + delete account_settings_ai_provider_config_path(config) + }.to change(AiProviderConfig, :count).by(-1) + end + end + + describe "POST /account/settings/ai_provider_configs/:id/activate" do + let!(:config_a) { create(:ai_provider_config, account: account, active: true) } + let!(:config_b) { create(:ai_provider_config, account: account, provider: "openai", active: false) } + + it "activates the specified config and deactivates others" do + post activate_account_settings_ai_provider_config_path(config_b) + + expect(config_b.reload).to be_active + expect(config_a.reload).not_to be_active + end + + it "redirects to account settings" do + post activate_account_settings_ai_provider_config_path(config_b) + expect(response).to redirect_to(account_settings_path) + end + end +end diff --git a/spec/services/ai_performance_summary_service_spec.rb b/spec/services/ai_performance_summary_service_spec.rb index a83701b5..c0216d37 100644 --- a/spec/services/ai_performance_summary_service_spec.rb +++ b/spec/services/ai_performance_summary_service_spec.rb @@ -6,12 +6,11 @@ let(:target) { "UsersController#index" } let(:stats) do { - avg_duration: 2500, - p95_duration: 5000, - request_count: 100, - slow_endpoints: [ - { endpoint: "/api/users", avg_duration: 2500, p95_duration: 5000 } - ] + total_requests: 100, + total_errors: 5, + error_rate: 5.0, + avg_ms: 2500, + p95_ms: 5000 } end @@ -20,78 +19,65 @@ end describe '#initialize' do - it 'accepts target and stats' do - service = described_class.new(target: target, stats: stats) + it 'accepts account, target, and stats' do + service = described_class.new(account: account, target: target, stats: stats) expect(service).to be_a(AiPerformanceSummaryService) end it 'accepts optional sample_event' do event = create(:event, project: project, account: account) - service = described_class.new(target: target, stats: stats, sample_event: event) + service = described_class.new(account: account, target: target, stats: stats, sample_event: event) expect(service).to be_a(AiPerformanceSummaryService) end end describe '#call' do - context 'when ANTHROPIC_API_KEY is missing' do - before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("ANTHROPIC_API_KEY").and_return(nil) - end - - it 'returns error' do - service = described_class.new(target: target, stats: stats) + context 'when no AI provider is configured' do + it 'returns missing_config error' do + service = described_class.new(account: account, target: target, stats: stats) result = service.call - expect(result[:error]).to eq("missing_api_key") + expect(result[:error]).to eq("missing_config") end end - context 'when ANTHROPIC_API_KEY is present' do - let(:api_response) do - { - "content" => [ - { - "type" => "text", - "text" => "## Performance Analysis\n\nThe endpoint is slow due to..." - } - ] - } + context 'when AI provider is configured' do + let!(:ai_config) do + create(:ai_provider_config, + account: account, + provider: "anthropic", + fast_model: "claude-haiku-4-5-20251001", + power_model: "claude-sonnet-4-6", + active: true) end - before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("ANTHROPIC_API_KEY").and_return("test-api-key") - - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return( - status: 200, - body: api_response.to_json, - headers: { 'Content-Type' => 'application/json' } - ) - end + it 'returns performance summary via RubyLLM' do + mock_message = double(content: "## Performance Analysis\n\nThe endpoint is slow due to...") + mock_chat = double(with_instructions: nil) + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_message) + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) - it 'returns performance summary' do - service = described_class.new(target: target, stats: stats) + service = described_class.new(account: account, target: target, stats: stats) result = service.call expect(result[:summary]).to include("Performance Analysis") end - it 'uses claude-opus-4 model' do - service = described_class.new(target: target, stats: stats) - service.call + it 'uses the fast_model from the active config' do + mock_message = double(content: "analysis") + mock_chat = double + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_message) - expect(WebMock).to have_requested(:post, "https://api.anthropic.com/v1/messages") - .with(body: hash_including("model" => "claude-opus-4-20250514")) - end + mock_ctx = double + allow(RubyLLM).to receive(:context).and_yield(double.as_null_object).and_return(mock_ctx) + allow(mock_ctx).to receive(:chat).with(model: "claude-haiku-4-5-20251001").and_return(mock_chat) - it 'includes target in request' do - service = described_class.new(target: target, stats: stats) - service.call + service = described_class.new(account: account, target: target, stats: stats) + result = service.call - expect(WebMock).to have_requested(:post, "https://api.anthropic.com/v1/messages") - .with { |req| req.body.include?("UsersController#index") } + expect(result[:summary]).to eq("analysis") end end end diff --git a/spec/services/ai_summary_service_spec.rb b/spec/services/ai_summary_service_spec.rb index 3d229bf2..e65f5b76 100644 --- a/spec/services/ai_summary_service_spec.rb +++ b/spec/services/ai_summary_service_spec.rb @@ -36,115 +36,81 @@ describe '#initialize' do it 'accepts issue and sample_event' do - service = described_class.new(issue: issue, sample_event: event) + service = described_class.new(account: account, issue: issue, sample_event: event) expect(service).to be_a(AiSummaryService) end it 'accepts optional github_client' do github_client = double("GithubClient") - service = described_class.new(issue: issue, sample_event: event, github_client: github_client) + service = described_class.new(account: account, issue: issue, sample_event: event, github_client: github_client) expect(service).to be_a(AiSummaryService) end end describe '#call' do - context 'when ANTHROPIC_API_KEY is missing' do + context 'when no AI provider is configured' do before do allow(ENV).to receive(:[]).and_call_original allow(ENV).to receive(:[]).with("ANTHROPIC_API_KEY").and_return(nil) + allow(ENV).to receive(:[]).with("OPENAI_API_KEY").and_return(nil) + allow(ENV).to receive(:[]).with("GEMINI_API_KEY").and_return(nil) end - it 'returns missing_api_key error' do - service = described_class.new(issue: issue, sample_event: event) + it 'returns missing_config error' do + service = described_class.new(account: account, issue: issue, sample_event: event) result = service.call - expect(result[:error]).to eq("missing_api_key") - expect(result[:message]).to include("ANTHROPIC_API_KEY") + expect(result[:error]).to eq("missing_config") end end - context 'when ANTHROPIC_API_KEY is present' do - let(:api_response) do - { - "content" => [ - { - "type" => "text", - "text" => "## Root Cause\n\nThe error occurs because...\n\n## Fix\n\n**Before:**\n\n```ruby\n@user.foo\n```\n\n**After:**\n\n```ruby\n@user&.foo\n```\n\n## Prevention\n\nUse safe navigation." - } - ] - } - end + context 'when AI provider is configured' do + let!(:ai_config) { create(:ai_provider_config, account: account, active: true) } + + let(:summary_text) { "## Root Cause\n\nThe error occurs because...\n\n## Fix\n\n**Before:**\n\n```ruby\n@user.foo\n```\n\n**After:**\n\n```ruby\n@user&.foo\n```\n\n## Prevention\n\nUse safe navigation." } before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("ANTHROPIC_API_KEY").and_return("test-api-key") - - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return( - status: 200, - body: api_response.to_json, - headers: { 'Content-Type' => 'application/json' } - ) + mock_message = double(content: summary_text) + mock_chat = double + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_message) + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) end it 'returns AI summary' do - service = described_class.new(issue: issue, sample_event: event) + service = described_class.new(account: account, issue: issue, sample_event: event) result = service.call expect(result[:summary]).to include("Root Cause") expect(result[:summary]).to include("Fix") end - it 'sends correct request to Anthropic API' do - service = described_class.new(issue: issue, sample_event: event) - service.call - - expect(WebMock).to have_requested(:post, "https://api.anthropic.com/v1/messages") - .with( - headers: { - 'x-api-key' => 'test-api-key', - 'anthropic-version' => '2023-06-01', - 'Content-Type' => 'application/json' - } - ) - end + it 'includes error details in prompt' do + mock_chat = double + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:ask) do |content| + expect(content).to include("NoMethodError") + double(content: summary_text) + end + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) - it 'uses claude-opus-4 model' do - service = described_class.new(issue: issue, sample_event: event) + service = described_class.new(account: account, issue: issue, sample_event: event) service.call - - expect(WebMock).to have_requested(:post, "https://api.anthropic.com/v1/messages") - .with(body: hash_including("model" => "claude-opus-4-20250514")) - end - - it 'uses max_tokens of 5000' do - service = described_class.new(issue: issue, sample_event: event) - service.call - - expect(WebMock).to have_requested(:post, "https://api.anthropic.com/v1/messages") - .with(body: hash_including("max_tokens" => 5000)) - end - - it 'includes error details in request' do - service = described_class.new(issue: issue, sample_event: event) - service.call - - expect(WebMock).to have_requested(:post, "https://api.anthropic.com/v1/messages") - .with { |req| req.body.include?("NoMethodError") } end end - context 'when API returns error' do - before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("ANTHROPIC_API_KEY").and_return("test-api-key") + context 'when AI call raises error' do + let!(:ai_config) { create(:ai_provider_config, account: account, active: true) } - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return(status: 500, body: "Internal Server Error") + before do + mock_chat = double + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_raise(RuntimeError, "API error") + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) end it 'returns ai_error' do - service = described_class.new(issue: issue, sample_event: event) + service = described_class.new(account: account, issue: issue, sample_event: event) result = service.call expect(result[:error]).to eq("ai_error") @@ -153,25 +119,20 @@ context 'with GitHub client for fetching related files' do let(:github_client) { double("GithubClient") } - let(:api_response) do - { - "content" => [{ "type" => "text", "text" => "## Root Cause\n\nTest" }] - } - end + let!(:ai_config) { create(:ai_provider_config, account: account, active: true) } before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("ANTHROPIC_API_KEY").and_return("test-api-key") - - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return(status: 200, body: api_response.to_json, headers: { 'Content-Type' => 'application/json' }) + mock_message = double(content: "## Root Cause\n\nTest") + mock_chat = double + allow(mock_chat).to receive(:with_instructions).and_return(mock_chat) + allow(mock_chat).to receive(:ask).and_return(mock_message) + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) end it 'fetches full error file from GitHub when client provided' do controller_content = Base64.encode64("class UsersController < ApplicationController\n def show\n @user = User.find(params[:id])\n @user.foo\n end\nend") model_content = Base64.encode64("class User < ApplicationRecord\n validates :name, presence: true\nend") - # Stub all potential GitHub API calls allow(github_client).to receive(:get).and_return(nil) allow(github_client).to receive(:get) .with("/repos/owner/repo/contents/app/controllers/users_controller.rb") @@ -180,7 +141,7 @@ .with("/repos/owner/repo/contents/app/models/user.rb") .and_return({ "content" => model_content }) - service = described_class.new(issue: issue, sample_event: event, github_client: github_client) + service = described_class.new(account: account, issue: issue, sample_event: event, github_client: github_client) result = service.call expect(result[:summary]).to be_present diff --git a/spec/services/github/branch_name_generator_spec.rb b/spec/services/github/branch_name_generator_spec.rb index 69a21028..7ae7d5e9 100644 --- a/spec/services/github/branch_name_generator_spec.rb +++ b/spec/services/github/branch_name_generator_spec.rb @@ -13,63 +13,39 @@ ) end - let(:service) { described_class.new(anthropic_key: "test-key") } + let!(:ai_config) { create(:ai_provider_config, account: account, active: true) } + let(:service) { described_class.new(account: account) } before do ActsAsTenant.current_tenant = account end describe '#initialize' do - it 'accepts anthropic_key' do - service = described_class.new(anthropic_key: "test-key") + it 'accepts account' do + service = described_class.new(account: account) expect(service).to be_a(Github::BranchNameGenerator) end end describe '#generate' do context 'with custom branch name' do - it 'returns sanitized custom branch name with prefix' do - result = service.generate(issue, "my-custom-branch") - - # Service adds ai-fix/ prefix if no prefix present - expect(result).to eq("ai-fix/my-custom-branch") - end - - it 'sanitizes invalid characters' do - result = service.generate(issue, "My Branch Name!") - - expect(result).to eq("ai-fix/my-branch-name") + it 'sanitizes and returns custom name' do + result = service.generate(issue, "my fix branch") + expect(result).to eq("ai-fix/my-fix-branch") end - it 'handles spaces and special characters' do - result = service.generate(issue, "fix: user login issue") - - expect(result).not_to include(" ") - expect(result).not_to include(":") - end - - it 'preserves existing prefix' do - result = service.generate(issue, "fix/my-branch") - - expect(result).to eq("fix/my-branch") + it 'preserves ai-fix/ prefix' do + result = service.generate(issue, "ai-fix/my-branch") + expect(result).to eq("ai-fix/my-branch") end end context 'with AI generation' do - let(:api_response) do - { - "content" => [ - { - "type" => "text", - "text" => "ai-fix/nomethoderror-users-show" - } - ] - } - end - before do - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return(status: 200, body: api_response.to_json, headers: { 'Content-Type' => 'application/json' }) + mock_message = double(content: "ai-fix/nomethoderror-users-show") + mock_chat = double + allow(mock_chat).to receive(:ask).and_return(mock_message) + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) end it 'generates branch name via AI' do @@ -78,28 +54,11 @@ expect(result).to be_present expect(result).to match(/^ai-fix\//) end - - it 'uses claude-opus-4 model' do - service.generate(issue) - - expect(WebMock).to have_requested(:post, "https://api.anthropic.com/v1/messages") - .with(body: hash_including("model" => "claude-opus-4-20250514")) - end - - it 'includes issue details in prompt' do - service.generate(issue) - - expect(WebMock).to have_requested(:post, "https://api.anthropic.com/v1/messages") - .with { |req| req.body.include?("NoMethodError") } - end end - context 'without AI (fallback)' do - let(:service) { described_class.new(anthropic_key: nil) } - + context 'without AI (no config)' do before do - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("ANTHROPIC_API_KEY").and_return(nil) + ai_config.update!(active: false) end it 'generates fallback branch name' do @@ -107,14 +66,15 @@ expect(result).to be_present expect(result).to start_with("ai-fix/") - expect(result).to include("no-method") # From NoMethodError + expect(result).to include("no-method") end end context 'when AI fails' do before do - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return(status: 500, body: "Internal Server Error") + mock_chat = double + allow(mock_chat).to receive(:ask).and_raise(RuntimeError, "API error") + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) end it 'falls back to generated name' do @@ -127,37 +87,23 @@ end describe 'branch name format' do - let(:api_response) do - { - "content" => [ - { - "type" => "text", - "text" => "ai-fix/nomethoderror-users-show" - } - ] - } - end - before do - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return(status: 200, body: api_response.to_json, headers: { 'Content-Type' => 'application/json' }) + mock_message = double(content: "ai-fix/nomethoderror-users-show") + mock_chat = double + allow(mock_chat).to receive(:ask).and_return(mock_message) + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) end it 'produces valid git branch name' do result = service.generate(issue) - # Git branch name rules - expect(result).not_to start_with("-") - expect(result).not_to end_with("-") - expect(result).not_to include(" ") - expect(result).not_to include("..") - expect(result).to match(/^[a-z0-9\-\/]+$/i) + expect(result).to match(/^[a-z0-9\-\/]+$/) + expect(result.length).to be <= 100 end - it 'is reasonably short' do + it 'starts with ai-fix/ prefix' do result = service.generate(issue) - - expect(result.length).to be <= 100 + expect(result).to start_with("ai-fix/") end end end diff --git a/spec/services/github/pr_content_generator_spec.rb b/spec/services/github/pr_content_generator_spec.rb index e7628f94..6d6ade79 100644 --- a/spec/services/github/pr_content_generator_spec.rb +++ b/spec/services/github/pr_content_generator_spec.rb @@ -48,7 +48,7 @@ SUMMARY end - let(:service) { described_class.new(anthropic_key: "test-key") } + let(:service) { described_class.new(account: account) } before do ActsAsTenant.current_tenant = account @@ -56,8 +56,8 @@ end describe '#initialize' do - it 'accepts anthropic_key' do - service = described_class.new(anthropic_key: "test-key") + it 'accepts account' do + service = described_class.new(account: account) expect(service).to be_a(Github::PrContentGenerator) end @@ -65,7 +65,7 @@ allow(ENV).to receive(:[]).and_call_original allow(ENV).to receive(:[]).with("ANTHROPIC_API_KEY").and_return("env-key") - service = described_class.new + service = described_class.new(account: account) expect(service).to be_a(Github::PrContentGenerator) end end @@ -122,8 +122,11 @@ end before do - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return(status: 200, body: api_response.to_json, headers: { 'Content-Type' => 'application/json' }) + create(:ai_provider_config, account: account, active: true) + mock_message = double(content: api_response.dig("content", 0, "text")) + mock_chat = double + allow(mock_chat).to receive(:ask).and_return(mock_message) + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) end it 'generates content via AI' do @@ -133,17 +136,16 @@ expect(result[:body]).to be_present end - it 'uses claude-opus-4 model' do - service.generate(issue) + it 'uses account AI provider config' do + result = service.generate(issue) - expect(WebMock).to have_requested(:post, "https://api.anthropic.com/v1/messages") - .with(body: hash_including("model" => "claude-opus-4-20250514")) + expect(result[:title]).to be_present end end context 'when issue has no AI summary and no API key' do let(:ai_summary) { nil } - let(:service) { described_class.new(anthropic_key: nil) } + let(:service) { described_class.new(account: account) } before do allow(ENV).to receive(:[]).and_call_original diff --git a/spec/services/github/pr_service_spec.rb b/spec/services/github/pr_service_spec.rb index 1c07c3d3..228e5964 100644 --- a/spec/services/github/pr_service_spec.rb +++ b/spec/services/github/pr_service_spec.rb @@ -46,12 +46,11 @@ let(:service) { described_class.new(project) } + let!(:ai_config) { create(:ai_provider_config, account: account, active: true) } + before do ActsAsTenant.current_tenant = account issue.events << event - - allow(ENV).to receive(:[]).and_call_original - allow(ENV).to receive(:[]).with("ANTHROPIC_API_KEY").and_return("test-anthropic-key") end describe '#initialize' do diff --git a/spec/services/github/simple_code_fix_applier_spec.rb b/spec/services/github/simple_code_fix_applier_spec.rb index ef33dc92..53b3c357 100644 --- a/spec/services/github/simple_code_fix_applier_spec.rb +++ b/spec/services/github/simple_code_fix_applier_spec.rb @@ -28,20 +28,22 @@ ) end - let(:service) { described_class.new(api_client: api_client, anthropic_key: "test-key") } + let(:service) { described_class.new(api_client: api_client, account: account) } + + let!(:ai_config) { create(:ai_provider_config, account: account, active: true) } before do ActsAsTenant.current_tenant = account end describe '#initialize' do - it 'accepts api_client and anthropic_key' do - service = described_class.new(api_client: api_client, anthropic_key: "test-key") + it 'accepts api_client and account' do + service = described_class.new(api_client: api_client, account: account) expect(service).to be_a(Github::SimpleCodeFixApplier) end it 'accepts optional source_branch' do - service = described_class.new(api_client: api_client, anthropic_key: "test-key", source_branch: "develop") + service = described_class.new(api_client: api_client, account: account, source_branch: "develop") expect(service).to be_a(Github::SimpleCodeFixApplier) end end @@ -111,10 +113,11 @@ def show .with("/repos/owner/repo/contents/app/models/user.rb") .and_return({ "content" => Base64.encode64("class User < ApplicationRecord\nend") }) - # Stub any POST to Anthropic API - stub_request(:post, "https://api.anthropic.com/v1/messages") - .with(headers: { 'X-Api-Key' => 'test-key' }) - .to_return(status: 200, body: ai_response.to_json, headers: { 'Content-Type' => 'application/json' }) + # Mock AI chat to return fix response + mock_message = double(content: ai_response.dig("content", 0, "text")) + mock_chat = double + allow(mock_chat).to receive(:ask).and_return(mock_message) + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) end it 'returns success with tree entry' do @@ -161,10 +164,11 @@ def show .with("/repos/owner/repo/contents/app/models/user.rb") .and_return({ "content" => Base64.encode64("class User < ApplicationRecord\nend") }) - # Still stub Anthropic in case fast path fails - stub_request(:post, "https://api.anthropic.com/v1/messages") - .with(headers: { 'X-Api-Key' => 'test-key' }) - .to_return(status: 200, body: ai_response.to_json, headers: { 'Content-Type' => 'application/json' }) + # Mock AI chat in case fast path fails + mock_message = double(content: ai_response.dig("content", 0, "text")) + mock_chat = double + allow(mock_chat).to receive(:ask).and_return(mock_message) + allow_any_instance_of(described_class).to receive(:ai_chat).and_return(mock_chat) end it 'attempts to apply fix (may use fast path or AI fallback)' do diff --git a/test/services/ai_performance_summary_service_test.rb b/test/services/ai_performance_summary_service_test.rb index c295f93c..7749a88b 100644 --- a/test/services/ai_performance_summary_service_test.rb +++ b/test/services/ai_performance_summary_service_test.rb @@ -2,6 +2,7 @@ class AiPerformanceSummaryServiceTest < ActiveSupport::TestCase setup do + @account = accounts(:default) @target = "UsersController#index" @stats = { avg_duration: 2500, @@ -13,50 +14,21 @@ class AiPerformanceSummaryServiceTest < ActiveSupport::TestCase } end - test "accepts target and stats on initialize" do - service = AiPerformanceSummaryService.new(target: @target, stats: @stats) + test "accepts account, target, and stats on initialize" do + service = AiPerformanceSummaryService.new(account: @account, target: @target, stats: @stats) assert service.is_a?(AiPerformanceSummaryService) end test "accepts optional sample_event" do event = events(:default) - service = AiPerformanceSummaryService.new(target: @target, stats: @stats, sample_event: event) + service = AiPerformanceSummaryService.new(account: @account, target: @target, stats: @stats, sample_event: event) assert service.is_a?(AiPerformanceSummaryService) end - # When ANTHROPIC_API_KEY is missing - - test "call returns missing_api_key error when no API key" do - original_key = ENV["ANTHROPIC_API_KEY"] - ENV["ANTHROPIC_API_KEY"] = nil - - service = AiPerformanceSummaryService.new(target: @target, stats: @stats) - result = service.call - - assert_equal "missing_api_key", result[:error] - ensure - ENV["ANTHROPIC_API_KEY"] = original_key - end - - # When ANTHROPIC_API_KEY is present - - test "call returns performance summary" do - api_response = { - "content" => [{ - "type" => "text", - "text" => "## Performance Analysis\n\nThe endpoint is slow due to..." - }] - } - - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return(status: 200, body: api_response.to_json, headers: { "Content-Type" => "application/json" }) - - ENV["ANTHROPIC_API_KEY"] = "test-api-key" - service = AiPerformanceSummaryService.new(target: @target, stats: @stats) + test "call returns missing_config error when no AI provider configured" do + service = AiPerformanceSummaryService.new(account: @account, target: @target, stats: @stats) result = service.call - assert_includes result[:summary], "Performance Analysis" - ensure - ENV["ANTHROPIC_API_KEY"] = nil + assert_equal "missing_config", result[:error] end end diff --git a/test/services/ai_summary_service_test.rb b/test/services/ai_summary_service_test.rb deleted file mode 100644 index 6ba22082..00000000 --- a/test/services/ai_summary_service_test.rb +++ /dev/null @@ -1,121 +0,0 @@ -require "test_helper" - -class AiSummaryServiceTest < ActiveSupport::TestCase - setup do - @issue = issues(:open_issue) - @event = events(:default) - @event.update!( - context: { - "structured_stack_trace" => [ - { - "file" => "app/controllers/users_controller.rb", - "line" => 25, - "method" => "show", - "in_app" => true, - "source_context" => { - "lines_before" => [" def show", " @user = User.find(params[:id])"], - "line_content" => " @user.foo", - "lines_after" => [" end"] - } - } - ] - } - ) - end - - test "accepts issue and sample_event on initialize" do - service = AiSummaryService.new(issue: @issue, sample_event: @event) - assert service.is_a?(AiSummaryService) - end - - test "accepts optional github_client" do - github_client = Object.new - service = AiSummaryService.new(issue: @issue, sample_event: @event, github_client: github_client) - assert service.is_a?(AiSummaryService) - end - - # When ANTHROPIC_API_KEY is missing - - test "call returns missing_api_key error when no API key" do - original_key = ENV["ANTHROPIC_API_KEY"] - ENV["ANTHROPIC_API_KEY"] = nil - - service = AiSummaryService.new(issue: @issue, sample_event: @event) - result = service.call - - assert_equal "missing_api_key", result[:error] - assert_includes result[:message], "ANTHROPIC_API_KEY" - ensure - ENV["ANTHROPIC_API_KEY"] = original_key - end - - # When ANTHROPIC_API_KEY is present - - test "call returns AI summary" do - api_response = { - "content" => [{ - "type" => "text", - "text" => "## Root Cause\n\nThe error occurs because...\n\n## Fix\n\n**Before:**\n\n```ruby\n@user.foo\n```\n\n**After:**\n\n```ruby\n@user&.foo\n```\n\n## Prevention\n\nUse safe navigation." - }] - } - - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return(status: 200, body: api_response.to_json, headers: { "Content-Type" => "application/json" }) - - ENV["ANTHROPIC_API_KEY"] = "test-api-key" - service = AiSummaryService.new(issue: @issue, sample_event: @event) - result = service.call - - assert_includes result[:summary], "Root Cause" - assert_includes result[:summary], "Fix" - ensure - ENV["ANTHROPIC_API_KEY"] = nil - end - - test "call returns ai_error when API fails" do - stub_request(:post, "https://api.anthropic.com/v1/messages") - .to_return(status: 500, body: "Internal Server Error") - - ENV["ANTHROPIC_API_KEY"] = "test-api-key" - service = AiSummaryService.new(issue: @issue, sample_event: @event) - result = service.call - - assert_equal "ai_error", result[:error] - ensure - ENV["ANTHROPIC_API_KEY"] = nil - end - - # SYSTEM_PROMPT - - test "SYSTEM_PROMPT includes required format instructions" do - assert_includes AiSummaryService::SYSTEM_PROMPT, "## Root Cause" - assert_includes AiSummaryService::SYSTEM_PROMPT, "## Suggested Fix" - assert_includes AiSummaryService::SYSTEM_PROMPT, "## Prevention" - end - - test "SYSTEM_PROMPT requires precise fix format with file and line" do - assert_includes AiSummaryService::SYSTEM_PROMPT, "### File 1:" - assert_includes AiSummaryService::SYSTEM_PROMPT, "**Line:**" - end - - test "call uses claude-haiku-4-5 model" do - api_response = { - "content" => [{ - "type" => "text", - "text" => "## Root Cause\n\nTest\n\n## Suggested Fix\n\nTest\n\n## Prevention\n\nTest" - }] - } - - stub_request(:post, "https://api.anthropic.com/v1/messages") - .with(body: hash_including("model" => "claude-haiku-4-5-20251001")) - .to_return(status: 200, body: api_response.to_json, headers: { "Content-Type" => "application/json" }) - - ENV["ANTHROPIC_API_KEY"] = "test-api-key" - service = AiSummaryService.new(issue: @issue, sample_event: @event) - result = service.call - - assert result[:summary].present? - ensure - ENV["ANTHROPIC_API_KEY"] = nil - end -end