diff --git a/CHANGELOG.rdoc b/CHANGELOG.rdoc index ad59f74f..4be47585 100644 --- a/CHANGELOG.rdoc +++ b/CHANGELOG.rdoc @@ -124,7 +124,7 @@ * Add normalize_subject option to remove numbers from email so that they thread (by @jjb) * Allow the user to provide a custom message and hash of data (by @jjb) * Add support for configurable background sections and a data partial (by @jeffrafter) - * Include timestamp of exception in notification body + * Include timestamp of exception in notification body * Add support for rack based session management (by @phoet) * Add ignore_crawlers option to ignore exceptions generated by crawlers * Add verbode_subject option to exclude exception message from subject (by @amishyn) diff --git a/README.md b/README.md index 3da9de23..3557430d 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ ExceptionNotification relies on notifiers to deliver notifications when errors o * [Slack notifier](#slack-notifier) * [Mattermost notifier](#mattermost-notifier) * [WebHook notifier](#webhook-notifier) +* [GitHub notifier](#github-notifier) But, you also can easily implement your own [custom notifier](#custom-notifier). @@ -773,6 +774,68 @@ Rails.application.config.middleware.use ExceptionNotification::Rack, For more HTTParty options, check out the [documentation](https://github.com/jnunemaker/httparty). +### GitHub notifier + +This notifier sends notifications, creating issues on GitHub. + +#### Usage + +Just add the [octokit](https://github.com/github/octokit) gem to your `Gemfile`: + +```ruby +gem 'octokit' +``` + +To configure it, you need to set the `repo`, `login` and `password` options, like this: + +```ruby +Rails.application.config.middleware.use ExceptionNotification::Rack, + :email => { + :email_prefix => "[PREFIX] ", + :sender_address => %{"notifier" }, + :exception_recipients => %w{exceptions@example.com} + }, + :github => { + :prefix => "[PREFIX] ", + :repo => 'owner/repo', + :login => ENV['GITHUB_LOGIN'], + :password => ENV['GITHUB_PASSWORD'] + } +``` + +#### Options + +##### repo + +*String, required* + +The repo owner and repo name, separated by a forward slash. + +##### login + +*String, required* + +A GitHub username with access rights to the repo + +##### password + +*String, required* + +The username's password. + +##### prefix + +*String, optional* + +A prefix prepended to the issue title. + +##### other options + +Authentication using OAuth tokens are not (yet) supported. + +Assignee, milestone and labels are not (yet) supported. + + ### Custom notifier Simply put, notifiers are objects which respond to `#call(exception, options)` method. Thus, a lambda can be used as a notifier as follow: diff --git a/exception_notification.gemspec b/exception_notification.gemspec index b86bff42..8b472162 100644 --- a/exception_notification.gemspec +++ b/exception_notification.gemspec @@ -32,4 +32,5 @@ Gem::Specification.new do |s| s.add_development_dependency "hipchat", ">= 1.0.0" s.add_development_dependency "carrier-pigeon", ">= 0.7.0" s.add_development_dependency "slack-notifier", ">= 1.0.0" + s.add_development_dependency "octokit", ">= 4.3.0" end diff --git a/lib/exception_notifier.rb b/lib/exception_notifier.rb index 5300c07b..cf0bb174 100644 --- a/lib/exception_notifier.rb +++ b/lib/exception_notifier.rb @@ -14,6 +14,7 @@ module ExceptionNotifier autoload :WebhookNotifier, 'exception_notifier/webhook_notifier' autoload :IrcNotifier, 'exception_notifier/irc_notifier' autoload :SlackNotifier, 'exception_notifier/slack_notifier' + autoload :GithubNotifier, 'exception_notifier/github_notifier' autoload :MattermostNotifier, 'exception_notifier/mattermost_notifier' class UndefinedNotifierError < StandardError; end diff --git a/lib/exception_notifier/github_notifier.rb b/lib/exception_notifier/github_notifier.rb new file mode 100644 index 00000000..ce94424f --- /dev/null +++ b/lib/exception_notifier/github_notifier.rb @@ -0,0 +1,148 @@ +require 'action_dispatch' +require 'pp' + +module ExceptionNotifier + class GithubNotifier < BaseNotifier + attr_accessor :body, :client, :title, :repo + + def initialize(options) + super + begin + @client = Octokit::Client.new(login: options.delete(:login), + password: options.delete(:password)) + @repo = options.delete(:repo) + @prefix = options.delete(:prefix) || '[Error] ' + end + end + + def call(exception, options = {}) + @exception = exception + @env = options[:env] + @data = (@env && @env['exception_notifier.exception_data'] || {}).merge(options[:data] || {}) + unless @env.nil? + @kontroller = @env['action_controller.instance'] + @request = ActionDispatch::Request.new(@env) + @request_hash = hash_from_request + @session = @request.session + @environment = @request.filtered_env + end + @title = compose_title + @body = compose_body + issue_options = { title: @title, body: @body } + send_notice(@exception, options, nil, issue_options) do |_msg, opts| + @client.create_issue(@repo, opts[:title], opts[:body]) if @client.basic_authenticated? + end + end + + private + + def compose_backtrace_section + return '' if @exception.backtrace.empty? + out = sub_title('Backtrace') + out << "
#{@exception.backtrace.join("\n")}
\n" + end + + def compose_body + body = compose_header + if @env.nil? + body << compose_backtrace_section + else + body << compose_request_section + body << compose_session_section + body << compose_environment_section + body << compose_backtrace_section + end + body << compose_data_section + end + + def compose_data_section + return '' if @data.empty? + out = sub_title('Data') + out << "
#{PP.pp(@data, '')}
" + end + + def compose_environment_section + out = sub_title('Environment') + max = @environment.keys.map(&:to_s).max { |a, b| a.length <=> b.length } + out << "
"
+      @environment.keys.map(&:to_s).sort.each do |key|
+        out << "* #{"%-*s: %s" % [max.length, key, inspect_object(@environment[key])]}\n"
+      end
+      out << "
" + end + + def compose_header + header = @exception.class.to_s =~ /^[aeiou]/i ? 'An' : 'A' + header << format(" %s occurred", @exception.class.to_s) + if @kontroller + header << format(" in %s#%s", + @kontroller.controller_name, + @kontroller.action_name) + end + header << format(":\n\n") + header << "
#{@exception.message}\n"
+      header << "#{@exception.backtrace.first}
" + end + + def compose_request_section + return '' if @request_hash.empty? + out = sub_title('Request') + out << "
* URL        : #{@request_hash[:url]}\n"
+      out << "* Referer    : #{@request_hash[:referer]}\n"
+      out << "* HTTP Method: #{@request_hash[:http_method]}\n"
+      out << "* IP address : #{@request_hash[:ip_address]}\n"
+      out << "* Parameters : #{@request_hash[:parameters].inspect}\n"
+      out << "* Timestamp  : #{@request_hash[:timestamp]}\n"
+      out << "* Server     : #{Socket.gethostname}\n"
+      if defined?(Rails) && Rails.respond_to?(:root)
+        out << "* Rails root : #{Rails.root}\n"
+      end
+      out << "* Process    : #{$$}
" + end + + def compose_session_section + out = sub_title('Session') + id = if @request.ssl? + '[FILTERED]' + else + rack_session_id = (@request.env["rack.session.options"] and @request.env["rack.session.options"][:id]) + (@request.session['session_id'] || rack_session_id).inspect + end + out << format("
* session id: %s\n", id)
+      out << "* data     : #{PP.pp(@request.session.to_hash, '')}
" + end + + def compose_title + subject = "#{@prefix}" + if @kontroller + subject << "#{@kontroller.controller_name}##{@kontroller.action_name}" + end + subject << " (#{@exception.class.to_s})" + subject.length > 120 ? subject[0...120] + "..." : subject + end + + def hash_from_request + { + referer: @request.referer, + http_method: @request.method, + ip_address: @request.remote_ip, + parameters: @request.filtered_parameters, + timestamp: Time.current, + url: @request.original_url + } + end + + def inspect_object(object) + case object + when Hash, Array + object.inspect + else + object.to_s + end + end + + def sub_title(text) + "\n\n-------------------- #{text} --------------------\n\n" + end + end +end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index d4de93a0..504313e7 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -13,7 +13,7 @@ ActiveRecord::Schema.define(version: 20110729022608) do - create_table "posts", force: true do |t| + create_table "posts", force: :cascade do |t| t.string "title" t.text "body" t.string "secret" diff --git a/test/exception_notifier/email_notifier_test.rb b/test/exception_notifier/email_notifier_test.rb index a4b449e5..7af16823 100644 --- a/test/exception_notifier/email_notifier_test.rb +++ b/test/exception_notifier/email_notifier_test.rb @@ -80,7 +80,7 @@ class EmailNotifierTest < ActiveSupport::TestCase ExceptionNotifier::EmailNotifier.normalize_digits('1 foo 12 bar 123 baz 1234') end - test "mail should be plain text and UTF-8 enconded by default" do + test "mail should be plain text and UTF-8 encoded by default" do assert_equal @mail.content_type, "text/plain; charset=UTF-8" end diff --git a/test/exception_notifier/github_notifier_test.rb b/test/exception_notifier/github_notifier_test.rb new file mode 100644 index 00000000..39be6876 --- /dev/null +++ b/test/exception_notifier/github_notifier_test.rb @@ -0,0 +1,73 @@ +require 'test_helper' +require 'octokit' + +class GithubNotifierTest < ActiveSupport::TestCase + + test "should create github issue if properly configured" do + Octokit::Client.any_instance.expects(:create_issue) + + options = { + :prefix => '[Prefix] ', + :repo => 'some/repo', + :login => 'login', + :password => 'password' + } + + github = ExceptionNotifier::GithubNotifier.new(options) + github.call(fake_exception, + :env => { "REQUEST_METHOD" => "GET", "rack.input" => "" }, + :data => {}) + end + + test "does not create an authenticated github client if badly configured" do + incomplete_options = { + :prefix => '[Prefix] ', + :repo => 'some/repo', + :login => nil, + :password => 'password' + } + + github = ExceptionNotifier::GithubNotifier.new(incomplete_options) + github.call(fake_exception, + :env => { "REQUEST_METHOD" => "GET", "rack.input" => "" }, + :data => {}) + + refute github.client.basic_authenticated? + end + + test "github issue is formed with data" do + Octokit::Client.any_instance.expects(:create_issue) + + options = { + :prefix => '[Prefix] ', + :repo => 'some/repo', + :login => 'login', + :password => 'password' + } + + github = ExceptionNotifier::GithubNotifier.new(options) + github.call(fake_exception, + :env => { "REQUEST_METHOD" => "GET", "rack.input" => "" }, + :data => {}) + + assert_includes github.title, '[Prefix] (ZeroDivisionError)' + assert_includes github.body, 'A ZeroDivisionError occurred:' + assert_includes github.body, 'divided by 0' + assert_includes github.body, '-------------------- Request --------------------' + assert_includes github.body, "* HTTP Method: GET" + assert_includes github.body, "-------------------- Session --------------------" + assert_includes github.body, "* session id: nil" + assert_includes github.body, "-------------------- Environment --------------------" + assert_includes github.body, "* REQUEST_METHOD : GET" + assert_includes github.body, "-------------------- Backtrace --------------------" + assert_includes github.body, "`fake_exception'" + end + + private + + def fake_exception + 5/0 + rescue Exception => e + e + end +end