diff --git a/CHANGELOG.md b/CHANGELOG.md index 960ff5c..9cd9946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## [Unreleased] +- Add a "Copy for LLM" button that copies an occurrence as an LLM-ready prompt, served from `/errors/:error_id/occurrences/:id.md` ([@bvalentino](https://github.com/fractaledmind/solid_errors/pull/97)) - Fix `undefined method 'bootstrap_svg'` crash on the error page when an error has no occurrences ## [0.7.0] - 2025-06-11 diff --git a/app/controllers/solid_errors/occurrences_controller.rb b/app/controllers/solid_errors/occurrences_controller.rb new file mode 100644 index 0000000..82a705d --- /dev/null +++ b/app/controllers/solid_errors/occurrences_controller.rb @@ -0,0 +1,18 @@ +module SolidErrors + class OccurrencesController < ApplicationController + # GET /errors/1/occurrences/1 + # + # Always renders the markdown prompt, whether or not the URL carried the + # `.md` extension. Served as text/plain so browsers display it inline + # instead of downloading it. + def show + @occurrence = Occurrence.find_by!(id: params[:id], error_id: params[:error_id]) + @error = @occurrence.error + + # Rendered as `text` rather than `md` so ActionView's ERB handler skips + # HTML escaping (its escape_ignore_list covers text/plain), which would + # otherwise mangle backtraces and exception messages. + render "show", formats: [:text], content_type: "text/plain", layout: false + end + end +end diff --git a/app/views/layouts/solid_errors/application.html.erb b/app/views/layouts/solid_errors/application.html.erb index 1c353bc..310a61a 100644 --- a/app/views/layouts/solid_errors/application.html.erb +++ b/app/views/layouts/solid_errors/application.html.erb @@ -51,6 +51,29 @@ document.querySelectorAll('[data-controller="fade"]').forEach(element => { fadeOut(element); }); + + // Copy links fetch their own href and put the body on the clipboard. If the + // clipboard is unavailable (non-secure origin) we never preventDefault, so + // the browser follows the link to the plain text instead. + document.addEventListener('click', event => { + const link = event.target.closest('[data-copy]') + if (!link || !navigator.clipboard) return + + event.preventDefault() + event.stopPropagation() + + const label = link.querySelector('span') + const original = label.textContent + + fetch(link.href) + .then(response => response.text()) + .then(text => navigator.clipboard.writeText(text)) + .then(() => { + label.textContent = 'Copied!' + setTimeout(() => { label.textContent = original }, 2000) + }) + .catch(() => { window.location = link.href }) + }); diff --git a/app/views/solid_errors/errors/_actions.html.erb b/app/views/solid_errors/errors/_actions.html.erb index da60125..821de9e 100644 --- a/app/views/solid_errors/errors/_actions.html.erb +++ b/app/views/solid_errors/errors/_actions.html.erb @@ -5,6 +5,10 @@ Back to errors <% end %> + <% if (latest_occurrence = error.occurrences.order(created_at: :desc).first) %> + <%= render "solid_errors/errors/copy_button", + url: error_occurrence_path(error, latest_occurrence) %> + <% end %> <% if error.resolved? %> <%= render 'solid_errors/errors/delete_button', error: error %> <% else %> diff --git a/app/views/solid_errors/errors/_copy_button.html.erb b/app/views/solid_errors/errors/_copy_button.html.erb new file mode 100644 index 0000000..ec9afe2 --- /dev/null +++ b/app/views/solid_errors/errors/_copy_button.html.erb @@ -0,0 +1,10 @@ +<%# locals: (url:, classes: "inline-flex items-center justify-center gap-2 font-medium cursor-pointer border rounded-lg py-3 px-5 bg-gray-100 text-initial border-gray-300 hover:ring-gray-200 hover:ring-8") -%> +<%# Deliberately an : when the clipboard API is unavailable (any non-secure + origin) or JS fails, the browser just follows the link to the plain text. -%> +<%= link_to url, class: classes, data: {copy: true} do %> + + + + + Copy for LLM +<% end %> diff --git a/app/views/solid_errors/occurrences/_occurrence.html.erb b/app/views/solid_errors/occurrences/_occurrence.html.erb index 722da96..074ac97 100644 --- a/app/views/solid_errors/occurrences/_occurrence.html.erb +++ b/app/views/solid_errors/occurrences/_occurrence.html.erb @@ -4,9 +4,14 @@ <%= tag.section id: dom_id(occurrence), class: "" do %> <%= tag.details open: open do %> - - <%= time_tag seen_at, seen_at %> - (<%= time_ago_in_words(seen_at) %> ago) + + + <%= time_tag seen_at, seen_at %> + (<%= time_ago_in_words(seen_at) %> ago) + + <%= render "solid_errors/errors/copy_button", + url: error_occurrence_path(occurrence.error_id, occurrence), + classes: "inline-flex items-center gap-1 text-sm font-medium cursor-pointer border rounded-lg py-1 px-2 bg-gray-100 text-initial border-gray-300 hover:ring-gray-200 hover:ring-4" %>
diff --git a/app/views/solid_errors/occurrences/show.text.erb b/app/views/solid_errors/occurrences/show.text.erb new file mode 100644 index 0000000..4130c66 --- /dev/null +++ b/app/views/solid_errors/occurrences/show.text.erb @@ -0,0 +1,40 @@ +<%- backtrace = @occurrence.parsed_backtrace -%> +Fix this error from the <%= Rails.application.class.module_parent_name %> application, reported by Solid Errors: + + +<%= @error.exception_class %> +<%= @error.message %> +<%= @error.severity %> +<%= @error.source %> +<%= @occurrence.created_at.utc.iso8601 %> +<%= @error.occurrences.count %> +<%= SolidErrors::BacktraceLine::RAILS_ROOT %> + +<%- backtrace.lines.each do |line| -%> +<%= line.filtered_method ? line.to_s : line.unparsed_line %> +<%- end -%> + +<%- if backtrace.application_lines.any? -%> + +<%- backtrace.application_lines.each do |line| -%> + +<%- line.source.each do |number, code| -%> +<%= number %>: <%= code.chomp %> +<%- end -%> + +<%- end -%> + +<%- end -%> +<%- if @occurrence.context.present? -%> + +<%= JSON.pretty_generate(@occurrence.context) %> + +<%- end -%> + + +Investigate and fix the root cause of this error: + +1. Use the backtrace and source context to locate where the error was raised in this codebase. +2. Check the request context for the conditions that triggered it. +3. Implement a fix that addresses the root cause, following the conventions of the surrounding code. +4. Add or update tests covering the failure case where applicable. diff --git a/config/routes.rb b/config/routes.rb index 726e38f..2f70803 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,7 @@ SolidErrors::Engine.routes.draw do get "/", to: "errors#index", as: :root - resources :errors, only: [:index, :show, :update, :destroy], path: "" + resources :errors, only: [:index, :show, :update, :destroy], path: "" do + resources :occurrences, only: [:show] + end end diff --git a/lib/solid_errors/engine.rb b/lib/solid_errors/engine.rb index 1a92aa5..f32fe40 100644 --- a/lib/solid_errors/engine.rb +++ b/lib/solid_errors/engine.rb @@ -17,6 +17,10 @@ class Engine < ::Rails::Engine end end + initializer "solid_errors.mime_types" do + Mime::Type.register "text/markdown", :md unless Mime[:md] + end + initializer "solid_errors.active_record.error_subscriber" do Rails.error.subscribe(SolidErrors::Subscriber.new) end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 9f768e5..9816d98 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -7,4 +7,6 @@ # Defines the root path route ("/") # root "posts#index" + + mount SolidErrors::Engine => "/solid_errors" end diff --git a/test/integration/solid_errors/markdown_test.rb b/test/integration/solid_errors/markdown_test.rb new file mode 100644 index 0000000..85f9b28 --- /dev/null +++ b/test/integration/solid_errors/markdown_test.rb @@ -0,0 +1,45 @@ +require "test_helper" + +class SolidErrors::MarkdownTest < ActionDispatch::IntegrationTest + setup do + begin + raise StandardError, "Something wrong" + rescue => exception + Rails.error.report(exception, context: {controller: "UsersController", action: "show"}) + end + + @error = SolidErrors::Error.last + @occurrence = @error.occurrences.last + end + + test "renders the occurrence as a plain text prompt" do + get "/solid_errors/#{@error.id}/occurrences/#{@occurrence.id}.md" + + assert_response :success + assert_equal "text/plain", response.media_type + + assert_includes response.body, "Investigate and fix the root cause of this error:" + assert_includes response.body, "StandardError" + assert_includes response.body, "" + assert_includes response.body, "\"controller\": \"UsersController\"" + # Not HTML escaped: the exception message survives verbatim. + assert_includes response.body, "Something wrong" + end + + test "404s when the occurrence does not belong to the error" do + other = SolidErrors::Error.create!(exception_class: "Other", message: "other", + severity: "error", fingerprint: "other") + + get "/solid_errors/#{other.id}/occurrences/#{@occurrence.id}.md" + + assert_response :not_found + end + + test "the error page links to the copy prompt" do + get "/solid_errors/#{@error.id}" + + assert_response :success + assert_includes response.body, "Copy for LLM" + assert_includes response.body, "/solid_errors/#{@error.id}/occurrences/#{@occurrence.id}" + end +end