lita-standups 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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