reviewlette 0.0.6
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +27 -0
- data/.rspec +1 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +114 -0
- data/Guardfile +24 -0
- data/LICENSE +21 -0
- data/README.md +95 -0
- data/Rakefile +5 -0
- data/bin/reviewlette +7 -0
- data/lib/matching.rb +0 -0
- data/lib/reviewlette.rb +202 -0
- data/lib/reviewlette/database.rb +77 -0
- data/lib/reviewlette/exceptions.rb +7 -0
- data/lib/reviewlette/github_connection.rb +45 -0
- data/lib/reviewlette/graph_gen.rb +62 -0
- data/lib/reviewlette/mail.rb +27 -0
- data/lib/reviewlette/trello_connection.rb +79 -0
- data/lib/reviewlette/vacations.rb +38 -0
- data/lib/reviewlette/version.rb +3 -0
- data/prophet.rb +4 -0
- data/reviewlette.gemspec +26 -0
- data/spec/database_spec.rb +34 -0
- data/spec/github_connection_spec.rb +96 -0
- data/spec/reviewlette_spec.rb +367 -0
- data/spec/spec_helper.rb +23 -0
- data/spec/support/request_stubbing.rb +305 -0
- data/spec/trello_connection_spec.rb +202 -0
- data/spec/vacation_spec.rb +12 -0
- data/task +1 -0
- metadata +145 -0
@@ -0,0 +1,77 @@
|
|
1
|
+
require 'sequel'
|
2
|
+
require 'fileutils'
|
3
|
+
module Reviewlette
|
4
|
+
|
5
|
+
class Database
|
6
|
+
|
7
|
+
FileUtils.mkdir_p("#{File.join(ENV['HOME'])}/.config/reviewlette/") unless Dir.exists?("#{ENV['HOME']}/.config/reviewlette")
|
8
|
+
FileUtils.cp ("#{File.dirname(__FILE__)}/../../reviewlette.db"), ("#{File.join(Dir.home)}" + '/.config/reviewlette/') unless File.exists?(("#{File.join(Dir.home)}" + '/.config/reviewlette/reviewlette.db'))
|
9
|
+
|
10
|
+
@path = "#{File.join(ENV['HOME'])}/.config/reviewlette"
|
11
|
+
DATABASE = Sequel.connect("sqlite://#{@path}/reviewlette.db")
|
12
|
+
|
13
|
+
attr_accessor :reviewer, :reviews
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
@reviewer = DATABASE.from(:reviewer)
|
17
|
+
@reviews = DATABASE.from(:reviews)
|
18
|
+
end
|
19
|
+
|
20
|
+
def count_up(reviewer)
|
21
|
+
pr_reviewer = @reviewer.where(:trello_name => reviewer).select(:trello_name).first.values.first
|
22
|
+
counter = @reviewer.where(:trello_name => pr_reviewer).select(:reviews).first.values.first
|
23
|
+
@reviewer.where(:trello_name => reviewer).update(:reviews => counter.next)
|
24
|
+
end
|
25
|
+
|
26
|
+
def add_pr_to_db(pr_name, reviewer)
|
27
|
+
@reviews.insert(:name => pr_name, :reviewer => reviewer, :created_at => Date.today)
|
28
|
+
count_up(reviewer)
|
29
|
+
end
|
30
|
+
|
31
|
+
def get_users_tel_entries
|
32
|
+
@reviewer.map([:tel_name]).flatten.select{|user| user unless user.nil?}
|
33
|
+
end
|
34
|
+
|
35
|
+
def get_users_gh_entries
|
36
|
+
@reviewer.map([:gh_name]).flatten.select{|user| user unless user.nil?}
|
37
|
+
end
|
38
|
+
|
39
|
+
def get_users_trello_entries
|
40
|
+
@reviewer.where(:vacation => 'false').map([:trello_name]).flatten.select{|user| user unless user.nil?}
|
41
|
+
end
|
42
|
+
|
43
|
+
def count_reviews(reviewer)
|
44
|
+
@reviews.where(:reviewer => reviewer).count
|
45
|
+
end
|
46
|
+
|
47
|
+
def find_gh_name_by_trello_name(trello_name)
|
48
|
+
@reviewer.where(:trello_name => trello_name).select(:gh_name).first.values.first
|
49
|
+
end
|
50
|
+
|
51
|
+
def set_vacation_flag(reviewer, state)
|
52
|
+
@reviewer.where(:tel_name => reviewer).update(:vacation => state)
|
53
|
+
end
|
54
|
+
|
55
|
+
def conscruct_graph_struct
|
56
|
+
data = []
|
57
|
+
get_users_trello_entries.each do |x|
|
58
|
+
data.push({ label: x, value: count_reviews(x) })
|
59
|
+
end
|
60
|
+
data
|
61
|
+
end
|
62
|
+
|
63
|
+
def conscruct_line_data
|
64
|
+
data = []
|
65
|
+
date_range = (Date.today - 7 )..(Date.today)
|
66
|
+
get_users_trello_entries.each do |name|
|
67
|
+
date_range.each do |date|
|
68
|
+
abc = {}
|
69
|
+
abc[:created_at] = date
|
70
|
+
abc[name] = @reviews.where(:reviewer => name, :created_at => date).select(:created_at).count
|
71
|
+
data.push(abc)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
data
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'octokit'
|
3
|
+
|
4
|
+
module Reviewlette
|
5
|
+
|
6
|
+
class GithubConnection
|
7
|
+
|
8
|
+
GITHUB_CONFIG = YAML.load_file("#{File.dirname(__FILE__)}/../../config/.github.yml")
|
9
|
+
|
10
|
+
attr_accessor :client, :repo
|
11
|
+
|
12
|
+
def initialize
|
13
|
+
gh_connection
|
14
|
+
end
|
15
|
+
|
16
|
+
def gh_connection
|
17
|
+
@client = Octokit::Client.new(:access_token => GITHUB_CONFIG['token'])
|
18
|
+
end
|
19
|
+
|
20
|
+
def get_branch_name(pr_id, repo)
|
21
|
+
@client.pull_requests(repo)[pr_id].head.ref
|
22
|
+
end
|
23
|
+
|
24
|
+
def list_pulls(repo)
|
25
|
+
@client.pull_requests(repo)
|
26
|
+
end
|
27
|
+
|
28
|
+
def pull_merged?(repo, number)
|
29
|
+
client.pull_merged?(repo, number)
|
30
|
+
end
|
31
|
+
|
32
|
+
def add_assignee(repo, number, title, body, name)
|
33
|
+
@client.update_issue(repo, number, title, body, :assignee => name)
|
34
|
+
end
|
35
|
+
|
36
|
+
def comment_on_issue(repo, number, name, trello_card_url)
|
37
|
+
@client.add_comment(repo, number, "@#{name} is your reviewer :thumbsup: check #{trello_card_url}")
|
38
|
+
end
|
39
|
+
|
40
|
+
def list_issues(repo)
|
41
|
+
@client.list_issues(repo)
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'json'
|
2
|
+
require_relative 'database'
|
3
|
+
module Reviewlette
|
4
|
+
|
5
|
+
class Graphgenerator
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@db = Reviewlette::Database.new
|
9
|
+
end
|
10
|
+
|
11
|
+
def write_to_graphs(filename, content)
|
12
|
+
File.open(filename, 'w') { |file| file.write(content) }
|
13
|
+
end
|
14
|
+
|
15
|
+
|
16
|
+
def model_graphs(data2, data, type)
|
17
|
+
@content = %Q|
|
18
|
+
<link rel="stylesheet" href="http://cdn.oesmith.co.uk/morris-0.5.1.css">
|
19
|
+
<script src="http://code.jquery.com/jquery-1.11.1.min.js"></script>
|
20
|
+
<script src="http://cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"></script>
|
21
|
+
<script src="http://cdn.oesmith.co.uk/morris-0.5.1.min.js"></script>
|
22
|
+
<meta http-equiv="refresh" content="15" />
|
23
|
+
|
24
|
+
<div id="Donut" style="height: 250px;"></div>
|
25
|
+
<div id="Line" style="height: 250px;"></div>
|
26
|
+
<div id="Bar" style="height: 250px;"></div>
|
27
|
+
|
28
|
+
<script>
|
29
|
+
new Morris.#{type}({
|
30
|
+
element: 'Donut',
|
31
|
+
data: #{data},
|
32
|
+
xkey: 'label',
|
33
|
+
colors: ['#80BFFF', '#F0F0F0', '#0000FF', '#00FFFF', '#FF00FF', '#C0C0C0'],
|
34
|
+
ykeys: ['value'],
|
35
|
+
labels: ['Value']
|
36
|
+
});
|
37
|
+
</script>
|
38
|
+
|
39
|
+
<script>
|
40
|
+
new Morris.Line({
|
41
|
+
element: 'Line',
|
42
|
+
data: #{data2},
|
43
|
+
xkey: 'created_at',
|
44
|
+
colors: ['#80BFFF', '#F0F0F0', '#0000FF', '#00FFFF', '#FF00FF', '#C0C0C0'],
|
45
|
+
ykeys: #{@db.get_users_trello_entries},
|
46
|
+
labels: #{@db.get_users_trello_entries}
|
47
|
+
});
|
48
|
+
</script>
|
49
|
+
|
50
|
+
<script>
|
51
|
+
new Morris.Bar({
|
52
|
+
element: 'Bar',
|
53
|
+
data: #{data},
|
54
|
+
xkey: 'label',
|
55
|
+
colors: ['#80BFFF', '#F0F0F0', '#0000FF', '#00FFFF', '#FF00FF', '#C0C0C0'],
|
56
|
+
ykeys: ['value'],
|
57
|
+
labels: ['Value']
|
58
|
+
});
|
59
|
+
</script>|
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
|
2
|
+
require 'net/smtp'
|
3
|
+
|
4
|
+
module Supporter
|
5
|
+
class Mailer
|
6
|
+
def send_email(to,opts={})
|
7
|
+
opts[:server] ||= 'localhost'
|
8
|
+
opts[:from] ||= 'review@lette.com'
|
9
|
+
opts[:from_alias] ||= 'Reviewlette'
|
10
|
+
opts[:subject] ||= "Commanding Officer of the Week"
|
11
|
+
opts[:body] ||= ""
|
12
|
+
|
13
|
+
msg = <<END_OF_MESSAGE
|
14
|
+
From: #{opts[:from_alias]} <#{opts[:from]}>
|
15
|
+
To: <#{to}>
|
16
|
+
Subject: #{opts[:subject]}
|
17
|
+
#{opts[:body]}
|
18
|
+
END_OF_MESSAGE
|
19
|
+
|
20
|
+
Net::SMTP.start(opts[:server]) do |smtp|
|
21
|
+
smtp.send_message msg, opts[:from], to
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
27
|
+
## make this more generic in order to use it either for COotW and PairProgramming sessions and reviews
|
@@ -0,0 +1,79 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'trello'
|
3
|
+
require 'logger'
|
4
|
+
require_relative 'database'
|
5
|
+
require_relative 'exceptions'
|
6
|
+
|
7
|
+
class Trello::Card
|
8
|
+
|
9
|
+
def assignees
|
10
|
+
@trello_connection = ::Reviewlette::TrelloConnection.new
|
11
|
+
member_ids.map{|id| @trello_connection.find_member_by_id(id)}
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
module Reviewlette
|
16
|
+
|
17
|
+
class TrelloConnection
|
18
|
+
|
19
|
+
attr_accessor :board
|
20
|
+
|
21
|
+
def initialize
|
22
|
+
setup_trello
|
23
|
+
end
|
24
|
+
|
25
|
+
def determine_reviewer(card)
|
26
|
+
raise AlreadyAssignedException, "Everyone on the team is assigned to the Card." if reviewer_exception_handler(card)
|
27
|
+
find_member_by_username(sample_reviewer(card))
|
28
|
+
end
|
29
|
+
|
30
|
+
def sample_reviewer(card)
|
31
|
+
(team - card.assignees.map(&:username)).sample
|
32
|
+
end
|
33
|
+
|
34
|
+
def reviewer_exception_handler(card)
|
35
|
+
(team - card.assignees.map(&:username)).count <= 0
|
36
|
+
end
|
37
|
+
|
38
|
+
def add_reviewer_to_card(reviewer, card)
|
39
|
+
card.add_member(reviewer) if reviewer
|
40
|
+
end
|
41
|
+
|
42
|
+
def comment_on_card(reviewer, card)
|
43
|
+
card.add_comment(reviewer) if reviewer
|
44
|
+
end
|
45
|
+
|
46
|
+
def move_card_to_list(card, column)
|
47
|
+
card.move_to_list(column)
|
48
|
+
end
|
49
|
+
|
50
|
+
def team
|
51
|
+
#where vacation is not false
|
52
|
+
@team ||= Reviewlette::Database.new.get_users_trello_entries
|
53
|
+
end
|
54
|
+
|
55
|
+
def find_column(column_name)
|
56
|
+
@board.lists.find {|x| x.name == column_name}
|
57
|
+
end
|
58
|
+
|
59
|
+
def find_member_by_username(username)
|
60
|
+
@board.members.find{|m| m.username == username}
|
61
|
+
end
|
62
|
+
|
63
|
+
def find_member_by_id(id)
|
64
|
+
@board.members.find{|m| m.id == id}
|
65
|
+
end
|
66
|
+
|
67
|
+
def find_card_by_id(id)
|
68
|
+
@board.cards.find{|c| c.short_id == id.to_i}
|
69
|
+
end
|
70
|
+
|
71
|
+
def setup_trello
|
72
|
+
Trello.configure do |config|
|
73
|
+
config.developer_public_key = ::Reviewlette::TRELLO_CONFIG1['consumerkey']
|
74
|
+
config.member_token = ::Reviewlette::TRELLO_CONFIG1['oauthtoken']
|
75
|
+
end
|
76
|
+
@board = Trello::Board.find(::Reviewlette::TRELLO_CONFIG1['board_id'])
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
require 'net/telnet'
|
2
|
+
module Reviewlette
|
3
|
+
|
4
|
+
class Vacations
|
5
|
+
|
6
|
+
def self.find_vacations(username)
|
7
|
+
vacations = []
|
8
|
+
tn = Net::Telnet.new('Host' => 'present.suse.de', 'Port' => 9874, 'Binmode' => false)
|
9
|
+
collect = false
|
10
|
+
tn.cmd(username) do |data|
|
11
|
+
data.split("\n").each do |l|
|
12
|
+
collect = true if l =~ /^Absence/
|
13
|
+
next unless collect
|
14
|
+
if l[0,1] == "-"
|
15
|
+
collect = false
|
16
|
+
next
|
17
|
+
end
|
18
|
+
dates = []
|
19
|
+
l.split(" ").each do |date|
|
20
|
+
unless date =~ /#{Time.now.year}/
|
21
|
+
next
|
22
|
+
end
|
23
|
+
dates.push(date)
|
24
|
+
end
|
25
|
+
case dates.size
|
26
|
+
when 1
|
27
|
+
vacations.push("#{dates[0]}")
|
28
|
+
when 2
|
29
|
+
vacations.push("#{dates[0]} - #{dates[1]}")
|
30
|
+
else
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
tn.close
|
35
|
+
vacations
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
data/prophet.rb
ADDED
data/reviewlette.gemspec
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'reviewlette/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "reviewlette"
|
8
|
+
spec.version = Reviewlette::VERSION
|
9
|
+
spec.authors = ["jschmid1"]
|
10
|
+
spec.email = ["jschmid@suse.de"]
|
11
|
+
spec.summary = %q{Randomly assignes a reviewer to your Pullrequest and corresponding Trello Card.}
|
12
|
+
spec.description = %q{Easy, fair and trackable labor division in your team.}
|
13
|
+
spec.homepage = "http://rubygems.org/gems/reviewlette"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0")
|
17
|
+
spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
|
18
|
+
spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_runtime_dependency 'ruby-trello', '=1.1.1'
|
22
|
+
spec.add_runtime_dependency 'octokit', '=3.1.0'
|
23
|
+
spec.add_runtime_dependency 'sequel', '=4.13.0'
|
24
|
+
spec.add_runtime_dependency 'sqlite3', '=1.3.9'
|
25
|
+
end
|
26
|
+
|
@@ -0,0 +1,34 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Reviewlette::Database do
|
4
|
+
|
5
|
+
|
6
|
+
subject { Reviewlette::Database.new }
|
7
|
+
|
8
|
+
describe '#count_reviews' do
|
9
|
+
|
10
|
+
it 'counts the reviews done by a single user' do
|
11
|
+
to_be_counted = [1,2,3,4]
|
12
|
+
expect(subject.instance_variable_get(:@reviews)).to receive(:where).and_return to_be_counted
|
13
|
+
expect(to_be_counted).to receive(:count).and_return to_be_counted.count
|
14
|
+
subject.count_reviews(subject.reviewer.first.values[1])
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
describe '#get_users_gh_entries' do
|
19
|
+
it 'gets all github usernames in #Array' do
|
20
|
+
expect(subject.reviewer).to receive(:map).and_return [['jschmid']]
|
21
|
+
subject.get_users_gh_entries
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
describe '#add_pr_to_db' do
|
26
|
+
|
27
|
+
it 'writes the name of the pr to db' do
|
28
|
+
expect(subject.reviews).to receive(:insert)
|
29
|
+
expect(subject).to receive(:count_up).with(subject.reviewer.first.values[1])
|
30
|
+
subject.add_pr_to_db('review_123', subject.reviewer.first.values[1])
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
|
3
|
+
describe Reviewlette::GithubConnection do
|
4
|
+
|
5
|
+
subject { Reviewlette::GithubConnection }
|
6
|
+
|
7
|
+
describe '.new' do
|
8
|
+
|
9
|
+
it 'sets up Github connection' do
|
10
|
+
config = Reviewlette::GithubConnection::GITHUB_CONFIG
|
11
|
+
expect(Octokit::Client).to receive(:new).with(:access_token => config['token'])
|
12
|
+
subject.new
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
describe '#pull_merged?' do
|
17
|
+
let( :connection ) { subject.new }
|
18
|
+
|
19
|
+
it 'checks if the pull is merged' do
|
20
|
+
allow(connection.client).to receive(:pull_merged?).with('true', 6).and_return true
|
21
|
+
expect(connection.pull_merged?('true', 6)).to be true
|
22
|
+
end
|
23
|
+
|
24
|
+
it 'checks if the pull is not merged' do
|
25
|
+
allow(connection.client).to receive(:pull_merged?).with('false', 5).and_return false
|
26
|
+
expect(connection.pull_merged?('false', 5)).to be false
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
describe '#add_assignee' do
|
31
|
+
let( :connection ) { subject.new }
|
32
|
+
|
33
|
+
it 'adds an assignee to the gh issue' do
|
34
|
+
params = [connection.repo, 4, 'title', 'body', 'name']
|
35
|
+
params2 = [connection.repo, 4, 'title', 'body', :assignee => 'name']
|
36
|
+
allow(connection.client).to receive(:update_issue).with(*params2).and_return true
|
37
|
+
expect(connection.add_assignee(*params)).to eq true
|
38
|
+
end
|
39
|
+
|
40
|
+
it 'fails to add an assignee to the gh issue' do
|
41
|
+
params = [connection.repo, 4, 'title', 'body', 'name']
|
42
|
+
params2 = [connection.repo, 4, 'title', 'body', :assignee => 'name']
|
43
|
+
allow(connection.client).to receive(:update_issue).with(*params2).and_return false
|
44
|
+
expect(connection.add_assignee(*params)).to eq false
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
describe '#comment_on_issue' do
|
49
|
+
let( :connection ) { subject.new }
|
50
|
+
|
51
|
+
it 'comments on a given issue' do
|
52
|
+
params = [connection.repo, 4, '@name is your reviewer :thumbsup: check url']
|
53
|
+
params2 = [connection.repo, 4, 'name', 'url']
|
54
|
+
allow(connection.client).to receive(:add_comment).with(*params).and_return true
|
55
|
+
expect(connection.comment_on_issue(*params2)).to eq true
|
56
|
+
end
|
57
|
+
|
58
|
+
it 'fails to comment on a given issue and fails' do
|
59
|
+
params = [connection.repo, 4, '@name is your reviewer :thumbsup: check url']
|
60
|
+
params2 = [connection.repo, 4, 'name', 'url']
|
61
|
+
allow(connection.client).to receive(:add_comment).with(*params).and_return false
|
62
|
+
expect(connection.comment_on_issue(*params2)).to eq false
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
describe '#list_issues' do
|
67
|
+
let( :connection ) { subject.new }
|
68
|
+
|
69
|
+
it 'fails to determine if an assignee is set' do
|
70
|
+
allow(connection.client).to receive_message_chain(:list_issues)
|
71
|
+
connection.list_issues(connection.repo)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
describe '#list_pulls' do
|
76
|
+
let( :connection ) { subject.new }
|
77
|
+
|
78
|
+
it 'lists a pullrequests for a given repository' do
|
79
|
+
expect(connection.client).to receive(:pull_requests)
|
80
|
+
connection.list_pulls(connection.repo)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
describe '#get_branch_name' do
|
85
|
+
let( :connection ) { subject.new }
|
86
|
+
|
87
|
+
it 'get branch name based on a repo and a pullrequest id' do
|
88
|
+
pulls = [double({ 'head' => double({ 'ref' => 'number'})})]
|
89
|
+
pr = pulls.first
|
90
|
+
expect(connection.client).to receive(:pull_requests).with(connection.repo).and_return pulls
|
91
|
+
expect(pulls).to receive(:[]).with(3).and_return pr
|
92
|
+
expect(pr).to receive(:head).and_return pr.head
|
93
|
+
connection.get_branch_name(3, connection.repo)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|