redmine_airbrake_backend 0.4.3 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Gemfile.lock +91 -0
- data/Rakefile +3 -8
- data/app/controllers/airbrake_controller.rb +107 -127
- data/app/controllers/airbrake_notice_controller.rb +25 -0
- data/app/controllers/airbrake_project_settings_controller.rb +5 -4
- data/app/controllers/airbrake_report_controller.rb +15 -0
- data/app/helpers/airbrake_helper.rb +40 -15
- data/app/models/airbrake_project_setting.rb +1 -0
- data/app/views/airbrake/issue_description/default.erb +52 -0
- data/config/routes.rb +5 -1
- data/db/migrate/20130708102357_create_airbrake_project_settings.rb +0 -2
- data/db/migrate/20131003151228_add_reopen_repeat_description.rb +0 -2
- data/lib/redmine_airbrake_backend.rb +0 -2
- data/lib/redmine_airbrake_backend/engine.rb +10 -10
- data/lib/redmine_airbrake_backend/error.rb +78 -0
- data/lib/redmine_airbrake_backend/patches/projects_helper.rb +8 -7
- data/lib/redmine_airbrake_backend/report/ios.rb +71 -0
- data/lib/redmine_airbrake_backend/request.rb +132 -0
- data/lib/redmine_airbrake_backend/request/json.rb +73 -0
- data/lib/redmine_airbrake_backend/request/xml.rb +121 -0
- data/lib/redmine_airbrake_backend/version.rb +2 -1
- data/redmine_airbrake_backend.gemspec +3 -3
- data/test/unit/notice_test.rb +1 -3
- metadata +11 -4
- data/app/views/airbrake/_issue_description.erb +0 -50
- data/lib/redmine_airbrake_backend/notice.rb +0 -109
@@ -0,0 +1,15 @@
|
|
1
|
+
# Controller for airbrake notices
|
2
|
+
class AirbrakeReportController < ::AirbrakeController
|
3
|
+
prepend_before_filter :parse_json_request
|
4
|
+
|
5
|
+
accept_api_auth :report
|
6
|
+
|
7
|
+
# Handle airbrake reports
|
8
|
+
def report
|
9
|
+
render json: {
|
10
|
+
report: {
|
11
|
+
id: (@results.first[:hash] rescue nil)
|
12
|
+
}
|
13
|
+
}
|
14
|
+
end
|
15
|
+
end
|
@@ -1,5 +1,7 @@
|
|
1
|
-
|
1
|
+
require 'htmlentities'
|
2
2
|
|
3
|
+
module AirbrakeHelper
|
4
|
+
# Wiki markup for a table
|
3
5
|
def format_table(data)
|
4
6
|
lines = []
|
5
7
|
data.each do |key, value|
|
@@ -9,6 +11,7 @@ module AirbrakeHelper
|
|
9
11
|
lines.join("\n")
|
10
12
|
end
|
11
13
|
|
14
|
+
# Wiki markup for logs
|
12
15
|
def format_log(data)
|
13
16
|
lines = []
|
14
17
|
data.each do |log|
|
@@ -18,41 +21,63 @@ module AirbrakeHelper
|
|
18
21
|
lines.join("\n")
|
19
22
|
end
|
20
23
|
|
24
|
+
# Wiki markup for a list item
|
21
25
|
def format_list_item(name, value)
|
22
26
|
return '' if value.blank?
|
27
|
+
|
23
28
|
"* *#{name}:* #{value}"
|
24
29
|
end
|
25
30
|
|
31
|
+
# Wiki markup for backtrace element with link to repository if possible
|
26
32
|
def format_backtrace_element(element)
|
27
33
|
@htmlentities ||= HTMLEntities.new
|
28
34
|
|
29
35
|
repository = repository_for_backtrace_element(element)
|
30
36
|
|
31
37
|
if repository.blank?
|
32
|
-
|
33
|
-
|
34
|
-
|
38
|
+
if element[:number].blank?
|
39
|
+
markup = "@#{@htmlentities.decode(element[:file])}@"
|
40
|
+
else
|
41
|
+
markup = "@#{@htmlentities.decode(element[:file])}:#{element[:number]}@"
|
42
|
+
end
|
35
43
|
else
|
36
|
-
|
44
|
+
filename = @htmlentities.decode(filename_for_backtrace_element(element))
|
45
|
+
|
46
|
+
if repository.identifier.blank?
|
47
|
+
markup = "source:\"#{filename}#L#{element[:number]}\""
|
48
|
+
else
|
49
|
+
markup = "source:\"#{repository.identifier}|#{filename}#L#{element[:number]}\""
|
50
|
+
end
|
37
51
|
end
|
38
52
|
|
39
|
-
|
53
|
+
markup + " in ??<notextile>#{@htmlentities.decode(element[:method])}</notextile>??"
|
40
54
|
end
|
41
55
|
|
42
56
|
private
|
43
57
|
|
44
58
|
def repository_for_backtrace_element(element)
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
59
|
+
return nil unless element[:file].start_with?('[PROJECT_ROOT]')
|
60
|
+
|
61
|
+
filename = filename_for_backtrace_element(element)
|
62
|
+
|
63
|
+
repositories_for_backtrace.find { |r| r.entry(filename) }
|
64
|
+
end
|
65
|
+
|
66
|
+
def repositories_for_backtrace
|
67
|
+
return @_bactrace_repositories unless @_bactrace_repositories.nil?
|
68
|
+
|
69
|
+
if request.repository.present?
|
70
|
+
@_bactrace_repositories = [request.repository]
|
71
|
+
else
|
72
|
+
@_bactrace_repositories = request.project.repositories.to_a
|
53
73
|
end
|
54
74
|
|
55
|
-
|
75
|
+
@_bactrace_repositories
|
56
76
|
end
|
57
77
|
|
78
|
+
def filename_for_backtrace_element(element)
|
79
|
+
return nil if element[:file].blank?
|
80
|
+
|
81
|
+
element[:file][14..-1]
|
82
|
+
end
|
58
83
|
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
<% if error.present? %>
|
2
|
+
h1. <%= error.message %>
|
3
|
+
|
4
|
+
<% if error.backtrace.present? %>
|
5
|
+
h2. Stacktrace:
|
6
|
+
|
7
|
+
p((. <%=raw error.backtrace.collect { |e| format_backtrace_element(e) }.join("\n") %>
|
8
|
+
<% end %>
|
9
|
+
<% end %>
|
10
|
+
|
11
|
+
<% if request.request.present? %>
|
12
|
+
h2. Request:
|
13
|
+
|
14
|
+
<%=format_list_item('URL', request.request[:url]) %>
|
15
|
+
<%=format_list_item('Component', request.request[:component]) %>
|
16
|
+
<%=format_list_item('Action', request.request[:action]) %>
|
17
|
+
|
18
|
+
<% if request.request[:params].present? %>
|
19
|
+
h2. Parameters:
|
20
|
+
|
21
|
+
<%=format_table(request.request[:params]) %>
|
22
|
+
<% end %>
|
23
|
+
|
24
|
+
<% if request.request[:cgi_data].present? %>
|
25
|
+
h2. Headers:
|
26
|
+
|
27
|
+
<%=format_table(request.request[:cgi_data]) %>
|
28
|
+
<% end %>
|
29
|
+
|
30
|
+
<% log = request.request[:session].delete(:log) if request.request[:session].present? %>
|
31
|
+
<% if request.request[:session].present? %>
|
32
|
+
h2. Session:
|
33
|
+
|
34
|
+
<%=format_table(request.request[:session]) %>
|
35
|
+
<% end %>
|
36
|
+
|
37
|
+
<% if log.present? %>
|
38
|
+
h2. Log:
|
39
|
+
|
40
|
+
<pre>
|
41
|
+
<%=format_log(log) %>
|
42
|
+
</pre>
|
43
|
+
<% end %>
|
44
|
+
<% end %>
|
45
|
+
|
46
|
+
<% if request.env.present? %>
|
47
|
+
h2. Environment
|
48
|
+
|
49
|
+
<%=format_list_item('Name', request.environment_name) %>
|
50
|
+
<%=format_list_item('Directory', request.env[:project_root]) %>
|
51
|
+
<%=format_list_item('Hostname', request.env[:hostname]) %>
|
52
|
+
<% end %>
|
data/config/routes.rb
CHANGED
@@ -1,6 +1,10 @@
|
|
1
1
|
Rails.application.routes.draw do
|
2
|
-
|
2
|
+
# notifier api
|
3
|
+
post '/notifier_api/v2/notices' => 'airbrake_notice#notice_xml'
|
4
|
+
post '/notifier_api/v3/notices' => 'airbrake_notice#notice_json'
|
5
|
+
post '/notifier_api/v3/reports/:type' => 'airbrake_report#report'
|
3
6
|
|
7
|
+
# settings
|
4
8
|
resources :projects do
|
5
9
|
member do
|
6
10
|
post 'airbrake_settings' => 'airbrake_project_settings#update'
|
@@ -1,5 +1,4 @@
|
|
1
1
|
class CreateAirbrakeProjectSettings < ActiveRecord::Migration
|
2
|
-
|
3
2
|
def up
|
4
3
|
create_table :airbrake_project_settings do |t|
|
5
4
|
t.references :project, index: true, null: false
|
@@ -14,5 +13,4 @@ class CreateAirbrakeProjectSettings < ActiveRecord::Migration
|
|
14
13
|
def down
|
15
14
|
drop_table :airbrake_project_settings
|
16
15
|
end
|
17
|
-
|
18
16
|
end
|
@@ -1,5 +1,4 @@
|
|
1
1
|
class AddReopenRepeatDescription < ActiveRecord::Migration
|
2
|
-
|
3
2
|
def up
|
4
3
|
add_column :airbrake_project_settings, :reopen_repeat_description, :boolean
|
5
4
|
|
@@ -16,5 +15,4 @@ class AddReopenRepeatDescription < ActiveRecord::Migration
|
|
16
15
|
def down
|
17
16
|
remove_column :airbrake_project_settings, :reopen_repeat_description
|
18
17
|
end
|
19
|
-
|
20
18
|
end
|
@@ -30,20 +30,20 @@ module RedmineAirbrakeBackend
|
|
30
30
|
|
31
31
|
def register_redmine_plugin
|
32
32
|
Redmine::Plugin.register :redmine_airbrake_backend do
|
33
|
-
name
|
34
|
-
author
|
35
|
-
author_url
|
36
|
-
description
|
37
|
-
url
|
38
|
-
version
|
39
|
-
requires_redmine :
|
40
|
-
directory
|
33
|
+
name 'Airbrake Backend'
|
34
|
+
author 'Florian Schwab'
|
35
|
+
author_url 'https://github.com/ydkn'
|
36
|
+
description 'Airbrake Backend for Redmine'
|
37
|
+
url 'https://github.com/ydkn/redmine_airbrake_backend'
|
38
|
+
version ::RedmineAirbrakeBackend::VERSION
|
39
|
+
requires_redmine version_or_higher: '2.4.0'
|
40
|
+
directory RedmineAirbrakeBackend.directory
|
41
41
|
|
42
42
|
project_module :airbrake do
|
43
|
-
permission :manage_airbrake, {airbrake: [:update]}
|
43
|
+
permission :manage_airbrake, { airbrake: [:update] }
|
44
44
|
end
|
45
45
|
|
46
|
-
settings default: {hash_field: '', occurrences_field: ''}, partial: 'settings/airbrake'
|
46
|
+
settings default: { hash_field: '', occurrences_field: '' }, partial: 'settings/airbrake'
|
47
47
|
end
|
48
48
|
end
|
49
49
|
end
|
@@ -0,0 +1,78 @@
|
|
1
|
+
require 'digest/md5'
|
2
|
+
|
3
|
+
module RedmineAirbrakeBackend
|
4
|
+
# Error received by airbrake
|
5
|
+
class Error
|
6
|
+
attr_accessor :request
|
7
|
+
attr_reader :type, :message, :backtrace
|
8
|
+
attr_reader :airbrake_hash, :subject, :attachments
|
9
|
+
|
10
|
+
def initialize(error_data)
|
11
|
+
# Data
|
12
|
+
@data = error_data
|
13
|
+
|
14
|
+
# Type
|
15
|
+
@type = @data[:type]
|
16
|
+
|
17
|
+
# Message
|
18
|
+
@message = @data[:message]
|
19
|
+
|
20
|
+
# Backtrace
|
21
|
+
@backtrace = @data[:backtrace]
|
22
|
+
|
23
|
+
# Attachments
|
24
|
+
@attachments = @data[:attachments]
|
25
|
+
|
26
|
+
# Hash
|
27
|
+
@airbrake_hash = generate_hash
|
28
|
+
|
29
|
+
# Subject
|
30
|
+
@subject = generate_subject
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def generate_hash
|
36
|
+
h = []
|
37
|
+
h << filter_hex_values(@type)
|
38
|
+
h << filter_hex_values(@message)
|
39
|
+
h += normalized_backtrace
|
40
|
+
|
41
|
+
Digest::MD5.hexdigest(h.compact.join("\n"))
|
42
|
+
end
|
43
|
+
|
44
|
+
def generate_subject
|
45
|
+
s = ''
|
46
|
+
|
47
|
+
if @type.blank? || @message.starts_with?("#{@type}:")
|
48
|
+
s = "[#{@airbrake_hash[0..7]}] #{@message}"
|
49
|
+
else
|
50
|
+
s = "[#{@airbrake_hash[0..7]}] #{@type}: #{@message}"
|
51
|
+
end
|
52
|
+
|
53
|
+
s[0..254].strip
|
54
|
+
end
|
55
|
+
|
56
|
+
def normalized_backtrace
|
57
|
+
if @backtrace.present?
|
58
|
+
@backtrace.map do |e|
|
59
|
+
"#{e[:file]}|#{normalize_method_name(e[:method])}|#{e[:number]}" rescue nil
|
60
|
+
end.compact
|
61
|
+
else
|
62
|
+
[]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def normalize_method_name(method_name)
|
67
|
+
name = e[:method]
|
68
|
+
.downcase
|
69
|
+
.gsub(/_\d+_/, '') # ruby blocks
|
70
|
+
|
71
|
+
filter_hex_values(name)
|
72
|
+
end
|
73
|
+
|
74
|
+
def filter_hex_values(value)
|
75
|
+
value.gsub(/0x[0-9a-f]+/, '')
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
@@ -8,17 +8,18 @@ module RedmineAirbrakeBackend::Patches
|
|
8
8
|
alias_method_chain :project_settings_tabs, :airbrake_backend_tab
|
9
9
|
end
|
10
10
|
|
11
|
+
# add airbrake tab to project settings
|
11
12
|
def project_settings_tabs_with_airbrake_backend_tab
|
12
13
|
tabs = project_settings_tabs_without_airbrake_backend_tab
|
13
14
|
|
14
|
-
tabs.push(
|
15
|
-
:
|
16
|
-
|
17
|
-
:
|
18
|
-
|
19
|
-
|
15
|
+
tabs.push(
|
16
|
+
name: 'airbrake',
|
17
|
+
action: :manage_airbrake,
|
18
|
+
partial: 'projects/settings/airbrake',
|
19
|
+
label: :project_module_airbrake
|
20
|
+
)
|
20
21
|
|
21
|
-
tabs.select{|tab| User.current.allowed_to?(tab[:action], @project)}
|
22
|
+
tabs.select { |tab| User.current.allowed_to?(tab[:action], @project) }
|
22
23
|
end
|
23
24
|
end
|
24
25
|
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module RedmineAirbrakeBackend
|
2
|
+
# Represents a report contained in a request
|
3
|
+
class Report
|
4
|
+
# iOS report
|
5
|
+
class Ios
|
6
|
+
# Parse an iOS crash log
|
7
|
+
def self.parse(data)
|
8
|
+
error = {
|
9
|
+
backtrace: [],
|
10
|
+
attachments: []
|
11
|
+
}
|
12
|
+
|
13
|
+
header_finished = false
|
14
|
+
next_line_is_message = false
|
15
|
+
crashed_thread = false
|
16
|
+
indicent_identifier = nil
|
17
|
+
|
18
|
+
data.split("\n").each do |line|
|
19
|
+
header_finished = true if line =~ /^(Application Specific Information|Last Exception Backtrace|Thread \d+( Crashed)?):$/
|
20
|
+
|
21
|
+
unless header_finished
|
22
|
+
key, value = line.split(':', 2)
|
23
|
+
|
24
|
+
next if key.blank? || value.blank?
|
25
|
+
|
26
|
+
error[:type] = value.strip if key.strip == 'Exception Type'
|
27
|
+
error[:message] = value.strip if key.strip == 'Exception Codes'
|
28
|
+
|
29
|
+
indicent_identifier = value.strip if key.strip == 'Incident Identifier'
|
30
|
+
end
|
31
|
+
|
32
|
+
if next_line_is_message
|
33
|
+
next_line_is_message = false
|
34
|
+
|
35
|
+
error[:message] = line
|
36
|
+
|
37
|
+
if line =~ /^\*\*\* Terminating app due to uncaught exception '([^']+)', reason: '\*\*\* (.*)'$/
|
38
|
+
error[:type] = Regexp.last_match(1)
|
39
|
+
error[:message] = Regexp.last_match(2)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
crashed_thread = false if line =~ /^Thread \d+:$/
|
44
|
+
|
45
|
+
if crashed_thread
|
46
|
+
if line =~ /^(\d+)\s+([^\s]+)\s+(0x[0-9a-f]+)\s+(.+) \+ (\d+)$/
|
47
|
+
error[:backtrace] << {
|
48
|
+
file: Regexp.last_match(2),
|
49
|
+
method: Regexp.last_match(4),
|
50
|
+
line: Regexp.last_match(5),
|
51
|
+
}
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
crashed_thread = true if error[:backtrace].blank? && line =~ /^(Last Exception Backtrace|Thread \d+ Crashed):$/
|
56
|
+
|
57
|
+
next_line_is_message = true if line =~ /^Application Specific Information:$/
|
58
|
+
end
|
59
|
+
|
60
|
+
return nil if error.blank?
|
61
|
+
|
62
|
+
error[:attachments] << {
|
63
|
+
filename: "#{indicent_identifier}.crash",
|
64
|
+
data: data
|
65
|
+
} if indicent_identifier.present?
|
66
|
+
|
67
|
+
error
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,132 @@
|
|
1
|
+
require 'json'
|
2
|
+
require 'base64'
|
3
|
+
require 'redmine_airbrake_backend/error'
|
4
|
+
|
5
|
+
module RedmineAirbrakeBackend
|
6
|
+
# Represents a request received by airbrake
|
7
|
+
class Request
|
8
|
+
class Error < StandardError; end
|
9
|
+
class Invalid < Error; end
|
10
|
+
|
11
|
+
attr_reader :api_key, :project, :tracker, :category, :priority, :assignee, :repository, :type
|
12
|
+
attr_reader :params, :notifier, :errors, :context, :request, :env
|
13
|
+
attr_reader :environment_name
|
14
|
+
|
15
|
+
def initialize(config, data = {})
|
16
|
+
# config
|
17
|
+
@config = self.class.parse_config(config)
|
18
|
+
raise Invalid.new('Encoded configuration in api-key is missing') if @config.blank?
|
19
|
+
|
20
|
+
# API key
|
21
|
+
@api_key = @config[:api_key]
|
22
|
+
raise Invalid.new('No or invalid api-key') if @api_key.blank?
|
23
|
+
|
24
|
+
# Project
|
25
|
+
@project = Project.where(identifier: @config[:project]).first
|
26
|
+
raise Invalid.new('No or invalid project') if @project.blank?
|
27
|
+
|
28
|
+
# Check configuration
|
29
|
+
raise Invalid.new('Custom field for notice hash is not configured!') if notice_hash_field.blank?
|
30
|
+
|
31
|
+
# Tracker
|
32
|
+
@tracker = record_for(@project.trackers, :tracker)
|
33
|
+
raise Invalid.new('No or invalid tracker') if @tracker.blank?
|
34
|
+
raise Invalid.new('Custom field for notice hash not available on selected tracker') if @tracker.custom_fields.where(id: notice_hash_field.id).first.blank?
|
35
|
+
|
36
|
+
# Category
|
37
|
+
@category = record_for(@project.issue_categories, :category)
|
38
|
+
|
39
|
+
# Priority
|
40
|
+
@priority = record_for(IssuePriority, :priority) || IssuePriority.default
|
41
|
+
|
42
|
+
# Assignee
|
43
|
+
@assignee = record_for(@project.users, :assignee, [:id, :login])
|
44
|
+
|
45
|
+
# Repository
|
46
|
+
@repository = @project.repositories.where(identifier: (@config[:repository] || '')).first
|
47
|
+
|
48
|
+
# Type
|
49
|
+
@type = @config[:type]
|
50
|
+
|
51
|
+
# Errors
|
52
|
+
@errors = (data[:errors] || [])
|
53
|
+
@errors.each { |e| e.request = self }
|
54
|
+
|
55
|
+
# Environment
|
56
|
+
@env = data[:env]
|
57
|
+
|
58
|
+
# Request
|
59
|
+
@request = data[:request]
|
60
|
+
|
61
|
+
# Context
|
62
|
+
@context = data[:context]
|
63
|
+
|
64
|
+
# Notifier
|
65
|
+
@notifier = data[:notifier]
|
66
|
+
|
67
|
+
# Params
|
68
|
+
@params = data[:params]
|
69
|
+
|
70
|
+
# Environment name
|
71
|
+
@environment_name = (@env[:environment_name].presence || @env[:name].presence) if @env.present?
|
72
|
+
@environment_name ||= @context[:environment].presence if @context.present?
|
73
|
+
end
|
74
|
+
|
75
|
+
def notice_hash_field
|
76
|
+
custom_field(:hash_field)
|
77
|
+
end
|
78
|
+
|
79
|
+
def occurrences_field
|
80
|
+
custom_field(:occurrences_field)
|
81
|
+
end
|
82
|
+
|
83
|
+
def reopen?
|
84
|
+
reopen_regexp = project_setting(:reopen_regexp)
|
85
|
+
|
86
|
+
return false if environment_name.blank? || reopen_regexp.blank?
|
87
|
+
|
88
|
+
!!(environment_name =~ /#{reopen_regexp}/i)
|
89
|
+
end
|
90
|
+
|
91
|
+
def reopen_repeat_description?
|
92
|
+
!!project_setting(:reopen_repeat_description)
|
93
|
+
end
|
94
|
+
|
95
|
+
private
|
96
|
+
|
97
|
+
def record_for(on, config_key, fields = [:id, :name])
|
98
|
+
fields.each do |field|
|
99
|
+
val = on.where(field => @config[config_key]).first
|
100
|
+
return val if val.present?
|
101
|
+
end
|
102
|
+
|
103
|
+
project_setting(config_key)
|
104
|
+
end
|
105
|
+
|
106
|
+
def project_setting(key)
|
107
|
+
return nil if @project.airbrake_settings.blank?
|
108
|
+
|
109
|
+
@project.airbrake_settings.send(key) if @project.airbrake_settings.respond_to?(key)
|
110
|
+
end
|
111
|
+
|
112
|
+
def custom_field(key)
|
113
|
+
@project.issue_custom_fields.where(id: global_setting(key)).first || CustomField.where(id: global_setting(key), is_for_all: true).first
|
114
|
+
end
|
115
|
+
|
116
|
+
def global_setting(key)
|
117
|
+
Setting.plugin_redmine_airbrake_backend[key]
|
118
|
+
end
|
119
|
+
|
120
|
+
def self.parse_config(api_key_data)
|
121
|
+
config = ::JSON.parse(api_key_data).symbolize_keys rescue nil
|
122
|
+
|
123
|
+
return config.symbolize_keys if config.is_a?(Hash)
|
124
|
+
|
125
|
+
config = ::JSON.parse(Base64.decode64(api_key_data)) rescue nil
|
126
|
+
|
127
|
+
return config.symbolize_keys if config.is_a?(Hash)
|
128
|
+
|
129
|
+
nil
|
130
|
+
end
|
131
|
+
end
|
132
|
+
end
|