lita-standups 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 82008368e3b720587006e5591b4bacb7f23d7aa4
4
+ data.tar.gz: 04a147d56cfba2c231ff58014fd5be92b5ff5bda
5
+ SHA512:
6
+ metadata.gz: eff194daa8fc8f234492b4449d0923d541a5c55fa75fd07d239d15d1a93cce3bf7dd6192ada5454387267822ae1db2b7d7b16e043dd947d5764d22ec37562e79
7
+ data.tar.gz: 3d307792f13acc18a50dc76c83069173cb1ccdfc10b72dba422cfe48f31186290cac7fb16c339e7618a5f796ef1a241efe1969add2d85b5beda5812172a45499
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/.rspec ADDED
@@ -0,0 +1,2 @@
1
+ --format=documentation
2
+ --color
data/.rubocop.yml ADDED
@@ -0,0 +1,51 @@
1
+ AllCops:
2
+ DisplayCopNames: true
3
+ TargetRubyVersion: 2.3
4
+
5
+
6
+ Style/Encoding:
7
+ Enabled: false
8
+
9
+ Style/FrozenStringLiteralComment:
10
+ Enabled: false
11
+
12
+ Style/ClassAndModuleChildren:
13
+ EnforcedStyle: compact
14
+
15
+ Style/Documentation:
16
+ Enabled: false
17
+
18
+ Style/AndOr:
19
+ EnforcedStyle: conditionals
20
+
21
+ Style/EmptyLinesAroundClassBody:
22
+ EnforcedStyle: empty_lines
23
+
24
+
25
+ Style/MultilineOperationIndentation:
26
+ EnforcedStyle: indented
27
+
28
+
29
+ Style/PercentLiteralDelimiters:
30
+ PreferredDelimiters:
31
+ '%': []
32
+ '%i': []
33
+ '%q': ()
34
+ '%Q': ()
35
+ '%r': '{}'
36
+ '%s': []
37
+ '%w': []
38
+ '%W': []
39
+ '%x': ()
40
+
41
+ Style/StringLiterals:
42
+ EnforcedStyle: single_quotes
43
+
44
+ Style/StringLiteralsInInterpolation:
45
+ EnforcedStyle: single_quotes
46
+
47
+ Style/GuardClause:
48
+ MinBodyLength: 1
49
+
50
+ Metrics/LineLength:
51
+ Max: 120
data/.travis.yml ADDED
@@ -0,0 +1,10 @@
1
+ language: ruby
2
+ rvm:
3
+ - 2.3.0
4
+ script: bundle exec rake
5
+ before_install:
6
+ - gem update --system
7
+ services:
8
+ - redis-server
9
+ notifications:
10
+ email: false
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source "https://rubygems.org"
2
+
3
+ gemspec
data/Guardfile ADDED
@@ -0,0 +1,37 @@
1
+ guard :bundler do
2
+ require 'guard/bundler'
3
+ require 'guard/bundler/verify'
4
+ helper = Guard::Bundler::Verify.new
5
+
6
+ files = ['Gemfile']
7
+ files += Dir['*.gemspec'] if files.any? { |f| helper.uses_gemspec?(f) }
8
+
9
+ # Assume files are symlinked from somewhere
10
+ files.each { |file| watch(helper.real_path(file)) }
11
+ end
12
+
13
+ # Note: The cmd option is now required due to the increasing number of ways
14
+ # rspec may be run, below are examples of the most common uses.
15
+ # * bundler: 'bundle exec rspec'
16
+ # * bundler binstubs: 'bin/rspec'
17
+ # * spring: 'bin/rspec' (This will use spring if running and you have
18
+ # installed the spring binstubs per the docs)
19
+ # * zeus: 'zeus rspec' (requires the server to be started separately)
20
+ # * 'just' rspec: 'rspec'
21
+
22
+ guard :rspec, cmd: "bundle exec rspec" do
23
+ require "guard/rspec/dsl"
24
+ dsl = Guard::RSpec::Dsl.new(self)
25
+
26
+ # Feel free to open issues for suggestions and improvements
27
+
28
+ # RSpec files
29
+ rspec = dsl.rspec
30
+ watch(rspec.spec_helper) { rspec.spec_dir }
31
+ watch(rspec.spec_support) { rspec.spec_dir }
32
+ watch(rspec.spec_files)
33
+
34
+ # Ruby files
35
+ ruby = dsl.ruby
36
+ dsl.watch_spec_files_for(ruby.lib_files)
37
+ end
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2016 Cristian Bica
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,41 @@
1
+ # lita-standups
2
+
3
+ [![Build Status](https://travis-ci.org/cristianbica/lita-standups.png?branch=master)](https://travis-ci.org/cristianbica/lita-standups)
4
+ [![Coverage Status](https://coveralls.io/repos/github/cristianbica/lita-standups/badge.svg?branch=master)](https://coveralls.io/github/cristianbica/lita-standups?branch=master)
5
+
6
+
7
+ lita-standups is a [Lita](http://lita.io) plugin which allows you to run standups with your team.
8
+
9
+ ## Installation
10
+
11
+ Add lita-standups to your Lita instance's Gemfile:
12
+
13
+ ``` ruby
14
+ gem "lita-standups"
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```
20
+ list standups - list configured standups
21
+ create standup - create a standup
22
+ show standup STANDUP_ID - shows details of a standup
23
+ delete standup STANDUP_ID - deletes a standup
24
+ schedule standup STANDUP_ID - schedule a standup
25
+ unschedule standup SCHEDULE_ID - unschedule a standup
26
+ list standups schedules - shows scheduled standups
27
+ show standup schedule SCHEDULE_ID - shows a scheduled standup
28
+ run standup STANDUP_ID with USERS - runs a standup now (users space/eparated)
29
+ list standup sessions - list all standups sessions
30
+ show standup session SESSION_ID - show a standups session details
31
+ ```
32
+
33
+ ## Contributing
34
+
35
+ 1. Fork it ( https://github.com/cristianbica/lita-standups/fork )
36
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
37
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
38
+ 4. Push to the branch (`git push origin my-new-feature`)
39
+ 5. Create a new Pull Request
40
+
41
+
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/gem_tasks"
2
+ require "rspec/core/rake_task"
3
+
4
+ RSpec::Core::RakeTask.new(:spec)
5
+
6
+ task default: :spec
@@ -0,0 +1,155 @@
1
+ module Lita
2
+ module Handlers
3
+ class Standups < Handler
4
+
5
+ route(/^list standups$/, :list_standups,
6
+ command: true,
7
+ help: { 'list standups' => 'list configured standups' })
8
+
9
+ route(/^create standup$/, :create_standup,
10
+ command: true,
11
+ help: { 'create standup' => 'create a standup' })
12
+
13
+ route(/^show standup (\w+)$/, :show_standup,
14
+ command: true,
15
+ help: { 'show standup STANDUP_ID' => 'shows details of a standup' })
16
+
17
+ route(/^delete standup (\w+)$/, :delete_standup,
18
+ command: true,
19
+ help: { 'delete standup STANDUP_ID' => 'deletes a standup' })
20
+
21
+ route(/^schedule standup (\w+)$/, :create_standups_schedule,
22
+ command: true,
23
+ help: { 'schedule standup STANDUP_ID' => 'schedule a standup' })
24
+
25
+ route(/^unschedule standup (\w+)$/, :delete_standups_schedule,
26
+ command: true,
27
+ help: { 'unschedule standup SCHEDULE_ID' => 'unschedule a standup' })
28
+
29
+ route(/^list standups schedules$/, :show_standups_schedule,
30
+ command: true,
31
+ help: { 'list standups schedules' => 'shows scheduled standups' })
32
+
33
+ route(/^show standup schedule (\w+)$/, :show_standup_schedule,
34
+ command: true,
35
+ help: { 'show standup schedule SCHEDULE_ID' => 'shows a scheduled standup' })
36
+
37
+ route(/^run standup (.+) with (.*)$/, :run_standup,
38
+ command: true,
39
+ help: { 'run standup STANDUP_ID with USERS' => 'runs a standup now (users space/comma separated)' })
40
+
41
+ route(/^list standup sessions$/, :list_standup_sessions,
42
+ command: true,
43
+ help: { 'list standup sessions' => 'list all standups sessions' })
44
+
45
+ route(/^show standup session (\w+)$/, :show_standup_session,
46
+ command: true,
47
+ help: { 'show standup session SESSION_ID' => 'show a standups session details' })
48
+
49
+ def list_standups(request)
50
+ standups = Models::Standup.all.to_a
51
+ message = "Standups found: #{standups.size}."
52
+ message << " Here they are: \n" if standups.size>0
53
+ message << standups.map(&:summary).join("\n")
54
+ request.reply message
55
+ end
56
+
57
+ def create_standup(request)
58
+ start_wizard Wizards::CreateStandup, request.message
59
+ end
60
+
61
+ def show_standup(request)
62
+ standup = Models::Standup[request.matches[0][0]]
63
+ if standup
64
+ request.reply "Here are the details of your standup: \n>>>\n#{standup.description}"
65
+ else
66
+ request.reply "I couldn't find a standup with ID=#{request.matches[0][0]}"
67
+ end
68
+ end
69
+
70
+ def delete_standup(request)
71
+ standup = Models::Standup[request.matches[0][0]]
72
+ if standup
73
+ standup.delete
74
+ request.reply "Standup with ID #{standup.id} has been deleted"
75
+ else
76
+ request.reply "I couldn't find a standup with ID=#{request.matches[0][0]}"
77
+ end
78
+ end
79
+
80
+ def create_standups_schedule(request)
81
+ standup = Models::Standup[request.matches[0][0]]
82
+ if standup
83
+ start_wizard Wizards::ScheduleStandup, request.message, 'standup_id' => standup.id
84
+ else
85
+ request.reply "I couldn't find a standup with ID=#{request.matches[0][0]}"
86
+ end
87
+ end
88
+
89
+ def show_standups_schedule(request)
90
+ schedules = Models::StandupSchedule.all.to_a
91
+ message = "Scheduled standups found: #{schedules.size}."
92
+ message << " Here they are: \n" if schedules.size>0
93
+ message << schedules.map(&:summary).join("\n")
94
+ request.reply message
95
+ end
96
+
97
+ def delete_standups_schedule(request)
98
+ schedule = Models::StandupSchedule[request.matches[0][0]]
99
+ if schedule
100
+ robot.unschedule_standup(schedule)
101
+ schedule.delete
102
+ request.reply "Schedule with ID #{schedule.id} has been deleted"
103
+ else
104
+ request.reply "I couldn't find a scheduled standup with ID=#{request.matches[0][0]}"
105
+ end
106
+ end
107
+
108
+ def show_standup_schedule(request)
109
+ schedule = Models::StandupSchedule[request.matches[0][0]]
110
+ if schedule
111
+ request.reply "Here are the details: \n>>>\n#{schedule.description}"
112
+ else
113
+ request.reply "I couldn't find a scheduled standup with ID=#{request.matches[0][0]}"
114
+ end
115
+ end
116
+
117
+ def run_standup(request)
118
+ standup = Models::Standup[request.matches[0][0]]
119
+ recipients = request.matches[0][1].to_s.gsub("@", "").split(/[\s,\n]/m).map(&:strip).map(&:presence).compact
120
+ if standup
121
+ request.reply "I'll run the standup shortly and post the results here. Thanks"
122
+ robot.run_standup standup.id, recipients, request.message.source.room
123
+ else
124
+ request.reply "I couldn't find a standup with ID=#{request.matches[0][0]}"
125
+ end
126
+ end
127
+
128
+ def list_standup_sessions(request)
129
+ sessions = Models::StandupSession.all.to_a
130
+ message = "Sessions found: #{sessions.size}."
131
+ message << " Here they are: \n" if sessions.size>0
132
+ message << sessions.map(&:summary).join("\n")
133
+ request.reply message
134
+ end
135
+
136
+ def show_standup_session(request)
137
+ session = Models::StandupSession[request.matches[0][0]]
138
+ if session
139
+ message = "Here are the standup session details: \n #{session.description}\n"
140
+ message << "\n*Responses:*\n"
141
+ message << session.report_message
142
+ request.reply message
143
+ else
144
+ request.reply "I couldn't find a standup session with ID=#{request.matches[0][0]}"
145
+ end
146
+ end
147
+
148
+ def self.const_missing(name)
149
+ Lita::Standups.const_defined?(name) ? Lita::Standups.const_get(name) : super
150
+ end
151
+
152
+ Lita.register_handler(self)
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,86 @@
1
+ module Lita
2
+ module Standups
3
+ class Manager
4
+
5
+ EXPIRATION_TIME = 3600
6
+
7
+ def self.run(robot:, standup_id:, recipients:, room:)
8
+ session = Models::StandupSession.create(
9
+ standup_id: standup_id,
10
+ recipients: recipients,
11
+ room: room
12
+ )
13
+ new(robot: robot, session: session).run
14
+ end
15
+
16
+ def self.run_schedule(robot:, schedule_id:)
17
+ schedule = Models::StandupSchedule[schedule_id]
18
+ session = Models::StandupSession.create(
19
+ standup_id: schedule.standup_id,
20
+ standup_schedule_id: schedule.id,
21
+ recipients: schedule.recipients,
22
+ room: schedule.channel
23
+ )
24
+ new(robot: robot, session: session).run
25
+ end
26
+
27
+ def self.abort_expired_standups(robot:)
28
+ Lita::Standups::Models::StandupResponse.find(status: "pending").union(status: "running").each do |response|
29
+ next unless Time.current - response.created_at > EXPIRATION_TIME
30
+ response.expired!
31
+ response.save
32
+ Lita::Wizard.cancel_wizard(response.user.id)
33
+ target = Lita::Source.new(user: response.user, room: nil, private_message: true)
34
+ robot.send_message target, "Expired. See you next time!"
35
+ end
36
+ end
37
+
38
+ def self.complete_finished_standups(robot:)
39
+ Lita::Standups::Models::StandupSession.find(status: "completed", results_sent: false).each do |session|
40
+ new(robot: robot, session: session).post_results
41
+ end
42
+ end
43
+
44
+ attr_accessor :robot, :session
45
+
46
+ def initialize(robot:, session:)
47
+ @robot = robot
48
+ @session = session
49
+ end
50
+
51
+ def standup
52
+ session.standup
53
+ end
54
+
55
+ def room
56
+ @room ||= Lita::Source.new(user: nil, room: session.room)
57
+ end
58
+
59
+ def run
60
+ session.running!
61
+ session.save
62
+ session.recipients.each { |recipient| ask_questions(recipient) }
63
+ end
64
+
65
+ def ask_questions(recipient)
66
+ user = Lita::User.fuzzy_find(recipient)
67
+ response = Models::StandupResponse.create(
68
+ standup_session_id: session.id,
69
+ user_id: user.id
70
+ )
71
+ dummy_source = Lita::Source.new(user: user, room: nil, private_message: true)
72
+ dummy_message = Lita::Message.new(robot, '', dummy_source)
73
+ Wizards::RunStandup.start robot, dummy_message, 'response_id' => response.id
74
+ end
75
+
76
+ def post_results
77
+ return if session.results_sent
78
+ message = "The standup '#{standup.name}' has finished. Here's what everyone posted:\n\n#{session.report_message}"
79
+ robot.send_message room, message
80
+ session.results_sent = true
81
+ session.save
82
+ end
83
+
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,70 @@
1
+ module Lita
2
+ module Standups
3
+ module Mixins
4
+ module Robot
5
+ def initialize(*args)
6
+ @scheduler = Rufus::Scheduler.new if scheduler_enabled?
7
+ super
8
+ end
9
+
10
+ # :nocov:
11
+ def scheduler
12
+ @scheduler
13
+ end
14
+ # :nocov:
15
+
16
+ def scheduler_enabled?
17
+ ENV['TEST'].nil?
18
+ end
19
+
20
+ def schedule_standups
21
+ return unless scheduler_enabled?
22
+ scheduler.jobs.each(&:unschedule)
23
+ scheduler.cron '* * * * *', tags: [:standup_schedules, :abort_expired] do |job|
24
+ Lita::Standups::Manager.abort_expired_standups(robot: self)
25
+ end
26
+ scheduler.cron '* * * * *', tags: [:standup_schedules, :complete_finished] do |job|
27
+ Lita::Standups::Manager.complete_finished_standups(robot: self)
28
+ end
29
+ Models::StandupSchedule.all.each do |standup_schedule|
30
+ schedule_standup(standup_schedule)
31
+ end
32
+ end
33
+
34
+ def schedule_standup(standup_schedule)
35
+ return unless scheduler_enabled?
36
+ scheduler.cron standup_schedule.cron_line, schedule_id: standup_schedule.id,
37
+ tags: [:standup_schedules, "standup_schedule_#{standup_schedule.id}"] do |job|
38
+ Lita::Standups::Manager.run_schedule(robot: self, schedule_id: job.opts[:schedule_id])
39
+ end
40
+ end
41
+
42
+ def unschedule_standup(standup_schedule)
43
+ return unless scheduler_enabled?
44
+ scheduler.jobs(tags: [:standup_schedules, "standup_schedule_#{standup_schedule.id}"]).each(&:unschedule)
45
+ end
46
+
47
+ def run_standup(standup_id, recipients, room_id)
48
+ if scheduler_enabled?
49
+ scheduler.in "5s", tags: [:standup_schedules, :run_standup] do |job|
50
+ Lita::Standups::Manager.run(robot: self, standup_id: standup_id, recipients: recipients, room: room_id)
51
+ end
52
+ else
53
+ Lita::Standups::Manager.run(robot: self, standup_id: standup_id, recipients: recipients, room: room_id)
54
+ end
55
+ end
56
+
57
+ # :nocov:
58
+ def run
59
+ schedule_standups
60
+ super
61
+ end
62
+ # :nocov:
63
+ end
64
+ end
65
+ end
66
+
67
+ class Robot
68
+ prepend ::Lita::Standups::Mixins::Robot
69
+ end
70
+ end
@@ -0,0 +1,31 @@
1
+ module Lita
2
+ module Standups
3
+ module Models
4
+ class Standup < Ohm::Model
5
+
6
+ include Ohm::Callbacks
7
+ include Ohm::Timestamps
8
+ include Ohm::DataTypes
9
+
10
+ attribute :name
11
+ attribute :questions, Type::Array
12
+
13
+ collection :schedules, StandupSchedule, :standup
14
+
15
+ def summary
16
+ "#{name} (ID: #{id}) - #{questions.size} question(s)"
17
+ end
18
+
19
+ def description
20
+ [
21
+ "ID: #{id}",
22
+ "Name: #{name}",
23
+ "Questions:",
24
+ questions.join("\n")
25
+ ].join("\n")
26
+ end
27
+
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,72 @@
1
+ module Lita
2
+ module Standups
3
+ module Models
4
+ class StandupResponse < Ohm::Model
5
+
6
+ include Ohm::Callbacks
7
+ include Ohm::Timestamps
8
+ include Ohm::DataTypes
9
+
10
+ attribute :status
11
+ attribute :user_id
12
+ attribute :answers, Type::Array
13
+
14
+ reference :standup_session, StandupSession
15
+
16
+ index :status
17
+ index :user_id
18
+
19
+ def user
20
+ @user ||= Lita::User.fuzzy_find(user_id)
21
+ end
22
+
23
+ def standup
24
+ standup_session.standup
25
+ end
26
+
27
+ def questions
28
+ standup.questions
29
+ end
30
+
31
+ def before_create
32
+ self.status ||= 'pending'
33
+ end
34
+
35
+ def after_save
36
+ standup_session.update_status if finished?
37
+ end
38
+
39
+ %w(pending running completed aborted expired).each do |status_name|
40
+ define_method("#{status_name}?") do
41
+ status == status_name
42
+ end
43
+ define_method("#{status_name}!") do
44
+ self.status = status_name
45
+ end
46
+ end
47
+
48
+ def finished?
49
+ completed? || aborted? || expired?
50
+ end
51
+
52
+ def report_message
53
+ message = "#{user.name} (a.k.a @#{user.mention_name})\n"
54
+ if answers.is_a?(Array)
55
+ questions.map.with_index do |question, index|
56
+ if answers[index]
57
+ message << "> *#{question}*\n"
58
+ answers[index].split("\n").each do |line|
59
+ message << "> #{line}\n"
60
+ end
61
+ end
62
+ message << "> \n" if index < questions.count - 1
63
+ end
64
+ else
65
+ message << "> *Expired*\n"
66
+ end
67
+ message
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,54 @@
1
+ module Lita
2
+ module Standups
3
+ module Models
4
+ class StandupSchedule < Ohm::Model
5
+
6
+ include Ohm::Callbacks
7
+ include Ohm::Timestamps
8
+ include Ohm::DataTypes
9
+
10
+ attribute :repeat
11
+ attribute :day_of_week
12
+ attribute :time, Type::Time
13
+ attribute :recipients, Type::Array
14
+ attribute :channel
15
+
16
+ reference :standup, Standup
17
+
18
+ def cron_line
19
+ [
20
+ time.min,
21
+ time.hour,
22
+ "*",
23
+ "*",
24
+ (weekly? ? day_of_week_index : "*")
25
+ ].join(" ")
26
+ end
27
+
28
+ def day_of_week_index
29
+ %w(sunday monday tuesday wednesday thursday friday saturday).index(day_of_week)
30
+ end
31
+
32
+ def summary
33
+ day_text = weekly? ? " on #{day_of_week}" : ""
34
+ "ID: #{id} - running standup #{standup.name} (ID: #{standup.id}) #{repeat}#{day_text} at #{time.strftime("%H:%M")}"
35
+ end
36
+
37
+ def description
38
+ [
39
+ "ID: #{id}",
40
+ "Standup: #{standup.name} (ID: #{standup.id})",
41
+ "Recipients: #{recipients.join(", ")}",
42
+ "Running #{repeat} " + (weekly? ? "on #{day_of_week} " : "") + "at #{time.strftime("%H:%M")}",
43
+ "Sending the result on #{channel}"
44
+ ].join("\n")
45
+ end
46
+
47
+ def weekly?
48
+ repeat == "weekly"
49
+ end
50
+
51
+ end
52
+ end
53
+ end
54
+ end