ipt 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 +7 -0
- data/.gitignore +3 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Changelog.md +6 -0
- data/Gemfile +9 -0
- data/Guardfile +19 -0
- data/LICENSE.txt +21 -0
- data/README.md +37 -0
- data/Rakefile +10 -0
- data/bin/console +14 -0
- data/bin/ipt +10 -0
- data/bin/setup +8 -0
- data/ipt.gemspec +36 -0
- data/lib/pt.rb +13 -0
- data/lib/pt/action.rb +280 -0
- data/lib/pt/cli.rb +163 -0
- data/lib/pt/client.rb +133 -0
- data/lib/pt/configuration.rb +65 -0
- data/lib/pt/data_row.rb +83 -0
- data/lib/pt/data_table.rb +107 -0
- data/lib/pt/io.rb +184 -0
- data/lib/pt/version.rb +3 -0
- metadata +235 -0
data/lib/pt/cli.rb
ADDED
@@ -0,0 +1,163 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'highline'
|
3
|
+
require 'tempfile'
|
4
|
+
require 'uri'
|
5
|
+
require 'thor'
|
6
|
+
require 'paint'
|
7
|
+
|
8
|
+
# String patch monkey
|
9
|
+
class String
|
10
|
+
%i[blue yellow green red white black magenta cyan bold].each do |color|
|
11
|
+
define_method(color) do
|
12
|
+
Paint[self, color]
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
module PT
|
18
|
+
class CLI < Thor
|
19
|
+
include PT::Action
|
20
|
+
include PT::IO
|
21
|
+
attr_reader :project
|
22
|
+
|
23
|
+
TYPE=%w[feature bug chore release]
|
24
|
+
ACTION = %w[show open start finish deliver accept reject done assign estimate tasks comment label edit unstart]
|
25
|
+
|
26
|
+
default_task :mywork
|
27
|
+
|
28
|
+
def initialize(*args)
|
29
|
+
super
|
30
|
+
@io = HighLine.new
|
31
|
+
@config = PT::Configuration.new
|
32
|
+
@client = @config.client || Client.new
|
33
|
+
@project = @client.project
|
34
|
+
end
|
35
|
+
|
36
|
+
%w[unscheduled started finished delivered accepted rejected].each do |state|
|
37
|
+
desc "#{state} <owner>", "show all #{state} stories"
|
38
|
+
define_method(state.to_sym) do |owner = nil|
|
39
|
+
filter = "state:#{state}"
|
40
|
+
filter << " owner:#{owner}" if owner
|
41
|
+
select_story_from_paginated_table(title: "#{state} stories") do |page|
|
42
|
+
@client.get_stories(filter: filter, page: page)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
ACTION.each do |action|
|
48
|
+
desc "#{action} [id]", "#{action} story"
|
49
|
+
define_method(action.to_sym) do |story_id = nil|
|
50
|
+
if story_id
|
51
|
+
if story = @client.project.story(story_id.to_i)
|
52
|
+
title("#{action} '#{story.name}'")
|
53
|
+
send("#{action}_story", story)
|
54
|
+
else
|
55
|
+
message("No matches found for '#{story_id}', please use a valid pivotal story Id")
|
56
|
+
return
|
57
|
+
end
|
58
|
+
else
|
59
|
+
method_name = "get_stories_to_#{action}"
|
60
|
+
story = select_story_from_paginated_table(default_action: action, title: "Stories to #{action}") do |page|
|
61
|
+
if @client.respond_to?(method_name.to_sym)
|
62
|
+
@client.send("get_stories_to_#{action}", page: page)
|
63
|
+
else
|
64
|
+
@client.get_stories(page: page)
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
TYPE.each do |type|
|
72
|
+
desc "#{type} <owner>", "show all #{type} stories"
|
73
|
+
define_method(type.to_sym) do |owner = nil|
|
74
|
+
filter = "story_type:#{type} -state:accepted"
|
75
|
+
filter << " owner:#{owner}" if owner
|
76
|
+
select_story_from_paginated_table(title: "#{type} stories") do |page|
|
77
|
+
@client.get_stories(filter: filter, page: page)
|
78
|
+
end
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
desc 'mywork', 'list all your stories'
|
83
|
+
def mywork
|
84
|
+
select_story_from_paginated_table(title: 'My Work') do |page|
|
85
|
+
@client.get_stories(filter: "owner:#{Settings[:user_name]} -state:accepted", page: page)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
%w[ current backlog ].each do |scope|
|
90
|
+
desc "#{scope}", 'list all stories for #{scope} iteration'
|
91
|
+
define_method(scope.to_sym) do
|
92
|
+
select_story_from_paginated_table(title: "#{scope} iteration") do |page|
|
93
|
+
@client.get_stories_from_iteration(scope: scope, page: page)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
desc "list [owner]", "list all stories from owner"
|
99
|
+
def list(owner = nil)
|
100
|
+
owner = choose_person.initials unless owner
|
101
|
+
select_story_from_paginated_table(title: "stories for #{owner}") do |page|
|
102
|
+
@client.get_stories(filter: "owner:#{owner} -state:accepted", page: page)
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
desc "recent", "show stories you've recently shown or commented on with pt"
|
107
|
+
def recent
|
108
|
+
title("Your recent stories from #{project_to_s}")
|
109
|
+
select_story_from_paginated_table(title: "recent stories") do |page|
|
110
|
+
@client.get_stories(filter: Settings[:recent_tasks].join(','), page: page)
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
desc 'create [title] --owner <owner> --type <type> -m', "create a new story (and include description ala git commit)"
|
115
|
+
long_desc <<-LONGDESC
|
116
|
+
create story with title [title]
|
117
|
+
|
118
|
+
--owner, -o set owner
|
119
|
+
|
120
|
+
--type , -t set story type
|
121
|
+
|
122
|
+
-m enable add description using vim
|
123
|
+
|
124
|
+
omit all parameters will start interactive mode
|
125
|
+
LONGDESC
|
126
|
+
option :type, aliases: :t
|
127
|
+
option :owner, aliases: :o
|
128
|
+
option :m, type: :boolean
|
129
|
+
def create(title =nil)
|
130
|
+
if title
|
131
|
+
owner_id = if options[:owner] && (owner = @client.find_member(options[:owner]))
|
132
|
+
owner.person.id
|
133
|
+
else
|
134
|
+
nil
|
135
|
+
end
|
136
|
+
description = edit_using_editor if options[:m]
|
137
|
+
params = {
|
138
|
+
name: title,
|
139
|
+
requested_by_id: Settings[:user_id],
|
140
|
+
owner_ids: [owner_id],
|
141
|
+
description: description
|
142
|
+
}
|
143
|
+
params[:story_type] = options[:type] if options[:type]
|
144
|
+
story = @client.create_story(params)
|
145
|
+
congrats("Story with title #{story.name} has been created \n #{story.url}")
|
146
|
+
else
|
147
|
+
create_interactive_story
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
desc "find [query] " ,"looks in your stories by title and presents it"
|
152
|
+
long_desc <<-LONGDESC
|
153
|
+
search using pivotal tracker search query
|
154
|
+
LONGDESC
|
155
|
+
def find(query=nil)
|
156
|
+
query ||= ask("Please type seach query") { |q| q.readline = true }
|
157
|
+
story = select_story_from_paginated_table(title: "Search result for #{query}")do |page|
|
158
|
+
@client.get_stories(filter: query, page: page)
|
159
|
+
end
|
160
|
+
show_story(story)
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
data/lib/pt/client.rb
ADDED
@@ -0,0 +1,133 @@
|
|
1
|
+
require 'uri'
|
2
|
+
require 'tracker_api'
|
3
|
+
|
4
|
+
module PT
|
5
|
+
class Client < SimpleDelegator
|
6
|
+
|
7
|
+
STORY_FIELDS=':default,requested_by(initials),owners(initials),tasks(complete,description),comments(text,file_attachment_ids,person(initials))'
|
8
|
+
|
9
|
+
attr_reader :config, :client, :total_record, :limit
|
10
|
+
|
11
|
+
def initialize
|
12
|
+
@client = TrackerApi::Client.new(token: Settings[:pivotal_api_key])
|
13
|
+
self.__setobj__(@client)
|
14
|
+
@limit = Settings[:limit] || 10
|
15
|
+
end
|
16
|
+
|
17
|
+
def total_page
|
18
|
+
@total_record = @client.last_response.env.response_headers["X-Tracker-Pagination-Total"]
|
19
|
+
@total_record ? (@total_record.to_f/limit).ceil : 1
|
20
|
+
end
|
21
|
+
|
22
|
+
def current_page
|
23
|
+
offset = @client.last_response.env.response_headers["X-Tracker-Pagination-Offset"]
|
24
|
+
offset ? ((offset.to_f/limit)+1).to_i.ceil : 1
|
25
|
+
end
|
26
|
+
|
27
|
+
def project
|
28
|
+
@client.project(Settings[:project_id])
|
29
|
+
end
|
30
|
+
|
31
|
+
def get_stories_to_estimate(params={})
|
32
|
+
params[:filter] = "owner:#{Settings[:user_name]} type:feature estimate:-1"
|
33
|
+
get_stories(params)
|
34
|
+
end
|
35
|
+
|
36
|
+
def get_stories_to_unstart(params={})
|
37
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:unstarted"
|
38
|
+
get_stories(params)
|
39
|
+
end
|
40
|
+
|
41
|
+
def get_stories_to_start(params={})
|
42
|
+
params[:filter] = "owner:#{Settings[:user_name]} type:feature,bug state:unscheduled,rejected,unstarted"
|
43
|
+
tasks = get_stories(params)
|
44
|
+
tasks.reject{ |t| (t.story_type == 'feature') && (!t.estimate) }
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_stories_to_finish(params={})
|
48
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:unscheduled,rejected"
|
49
|
+
get_stories(params)
|
50
|
+
end
|
51
|
+
|
52
|
+
def get_stories_to_deliver(params={})
|
53
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:delivered,accepted,rejected"
|
54
|
+
get_stories(params)
|
55
|
+
end
|
56
|
+
|
57
|
+
def get_stories_to_accept(params={})
|
58
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:accepted"
|
59
|
+
get_stories(params)
|
60
|
+
end
|
61
|
+
|
62
|
+
def get_stories_to_reject(params={})
|
63
|
+
params[:filter] = "owner:#{Settings[:user_name]} -state:rejected"
|
64
|
+
get_stories(params)
|
65
|
+
end
|
66
|
+
|
67
|
+
def get_stories_to_assign(params={})
|
68
|
+
params[:filter] = "-state:accepted"
|
69
|
+
get_stories(params)
|
70
|
+
end
|
71
|
+
|
72
|
+
def get_stories(params={})
|
73
|
+
limit = @limit || 10
|
74
|
+
page = params[:page] || 0
|
75
|
+
offset = page*limit
|
76
|
+
filter = params[:filter] || '-state=accepted'
|
77
|
+
project.stories limit: limit, fields: STORY_FIELDS, auto_paginate: false, offset: offset, filter: filter
|
78
|
+
end
|
79
|
+
|
80
|
+
def get_stories_from_iteration(params={})
|
81
|
+
page = params[:page] || 0
|
82
|
+
puts "page #{page}"
|
83
|
+
scope = params[:scope] || 'current'
|
84
|
+
project.iterations(scope: scope, fields: ":default,stories(#{STORY_FIELDS})")[page]&.stories || []
|
85
|
+
end
|
86
|
+
|
87
|
+
|
88
|
+
def get_member(query)
|
89
|
+
member = project.memberships.select{ |m| m.person.name.downcase.start_with?(query.downcase) || m.person.initials.downcase == query.downcase }
|
90
|
+
member.empty? ? nil : member.first
|
91
|
+
end
|
92
|
+
|
93
|
+
def find_member(query)
|
94
|
+
project.memberships.detect do |m|
|
95
|
+
m.person.name.downcase.start_with?(query.downcase) || m.person.initials.downcase == query.downcase
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def get_members
|
100
|
+
project.memberships fields: ':default,person'
|
101
|
+
end
|
102
|
+
|
103
|
+
|
104
|
+
def mark_task_as(task, state)
|
105
|
+
task.current_state = state
|
106
|
+
task.save
|
107
|
+
end
|
108
|
+
|
109
|
+
def estimate_story(task, points)
|
110
|
+
task.estimate = points
|
111
|
+
task.save
|
112
|
+
end
|
113
|
+
|
114
|
+
def assign_story(story, owner)
|
115
|
+
story.add_owner(owner)
|
116
|
+
story.save
|
117
|
+
end
|
118
|
+
|
119
|
+
def add_label(task, label)
|
120
|
+
task.add_label(label)
|
121
|
+
task.save
|
122
|
+
end
|
123
|
+
|
124
|
+
def comment_task(story, comment)
|
125
|
+
task.create_comment(text: comment)
|
126
|
+
end
|
127
|
+
|
128
|
+
def create_story(args)
|
129
|
+
project.create_story(args)
|
130
|
+
end
|
131
|
+
|
132
|
+
end
|
133
|
+
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'config'
|
2
|
+
module PT
|
3
|
+
class Configuration
|
4
|
+
include PT::IO
|
5
|
+
GLOBAL_CONFIG_PATH = ENV['HOME'] + "/.pt.yml"
|
6
|
+
LOCAL_CONFIG_PATH = Dir.pwd + '/.pt.yml'
|
7
|
+
|
8
|
+
attr_reader :client
|
9
|
+
|
10
|
+
def initialize
|
11
|
+
Config.load_and_set_settings(GLOBAL_CONFIG_PATH, get_local_config_path)
|
12
|
+
unless (Settings[:pivotal_api_key] ||= ENV['PIVOTAL_API_KEY'])
|
13
|
+
message "I can't find info about your Pivotal Tracker account in #{GLOBAL_CONFIG_PATH}."
|
14
|
+
Settings[:pivotal_api_key] = ask "What is your token?"
|
15
|
+
congrats "Thanks!",
|
16
|
+
"Your API id is " + Settings[:pivotal_api_key],
|
17
|
+
"I'm saving it in #{GLOBAL_CONFIG_PATH} so you don't have to log in again."
|
18
|
+
save_config({"pivotal_api_key" => Settings[:pivotal_api_key]}, GLOBAL_CONFIG_PATH)
|
19
|
+
end
|
20
|
+
@client = Client.new
|
21
|
+
Settings[:user_name] ||= @client.me.name || ENV['pivotal_user_name']
|
22
|
+
Settings[:user_id] ||= @client.me.id || ENV['pivotal_user_id']
|
23
|
+
Settings[:user_initials] ||= @client.me.initials || ENV['pivotal_user_initials']
|
24
|
+
|
25
|
+
|
26
|
+
unless (Settings[:project_id] ||= ENV['PIVOTAL_PROJECT_ID'])
|
27
|
+
projects = ProjectTable.new(@client.projects)
|
28
|
+
project = select("Please select the project for the current directory", projects)
|
29
|
+
Settings[:project_id], Settings[:project_name] = project.id, project.name
|
30
|
+
end
|
31
|
+
|
32
|
+
save_config(Settings.to_hash, get_local_config_path())
|
33
|
+
end
|
34
|
+
|
35
|
+
def get_local_config_path
|
36
|
+
# If the local config path does not exist, check to see if we're in a git repo
|
37
|
+
# And if so, try the top level of the checkout
|
38
|
+
if (!File.exist?(LOCAL_CONFIG_PATH) && system('git rev-parse 2> /dev/null'))
|
39
|
+
return `git rev-parse --show-toplevel`.chomp() + '/.pt.yml'
|
40
|
+
else
|
41
|
+
return LOCAL_CONFIG_PATH
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def save_config(config, path)
|
46
|
+
config = stringify(config)
|
47
|
+
File.new(path, 'w') unless File.exists?(path)
|
48
|
+
File.open(path, 'w') {|f| f.write(config.to_yaml) }
|
49
|
+
end
|
50
|
+
|
51
|
+
private
|
52
|
+
|
53
|
+
def check_local_config_path
|
54
|
+
if GLOBAL_CONFIG_PATH == get_local_config_path()
|
55
|
+
error("Please execute .pt inside your project directory and not in your home.")
|
56
|
+
exit
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def stringify(hash)
|
61
|
+
hash.inject({}) { |memo, (k,v)| memo[k.to_s] = v; memo }
|
62
|
+
end
|
63
|
+
|
64
|
+
end
|
65
|
+
end
|
data/lib/pt/data_row.rb
ADDED
@@ -0,0 +1,83 @@
|
|
1
|
+
require 'iconv' unless "older_ruby?".respond_to?(:force_encoding)
|
2
|
+
|
3
|
+
module PT
|
4
|
+
class DataRow
|
5
|
+
|
6
|
+
attr_accessor :num, :record, :state, :owners
|
7
|
+
|
8
|
+
def initialize(orig, dataset)
|
9
|
+
@record = orig
|
10
|
+
@num = dataset.index(orig) + 1
|
11
|
+
if defined? orig.current_state
|
12
|
+
@state = orig.current_state
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def method_missing(method)
|
17
|
+
str = @record.send(method).to_s
|
18
|
+
str.respond_to?(:force_encoding) ? str.force_encoding('utf-8') : Iconv.iconv('UTF-8', 'UTF-8', str)
|
19
|
+
end
|
20
|
+
|
21
|
+
def to_s
|
22
|
+
@record.send(self.to_s_attribute)
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_s_attribute
|
26
|
+
@n.to_s
|
27
|
+
end
|
28
|
+
|
29
|
+
def name
|
30
|
+
_name = @record.name
|
31
|
+
if _name.size > 15
|
32
|
+
_name[0..15] + '...'
|
33
|
+
else
|
34
|
+
_name
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def owners
|
39
|
+
if @record.instance_variable_get(:@owners).present?
|
40
|
+
@record.owners.map{|o| o.initials == Settings[:user_initials] ? o.initials.red : o.initials }.join(',')
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def state
|
45
|
+
state = @record.current_state
|
46
|
+
case state
|
47
|
+
when 'delivered'
|
48
|
+
state.yellow
|
49
|
+
when 'finished'
|
50
|
+
state.blue
|
51
|
+
when 'accepted'
|
52
|
+
state.green
|
53
|
+
when 'rejected'
|
54
|
+
state.red
|
55
|
+
when 'started'
|
56
|
+
state.white
|
57
|
+
else
|
58
|
+
state.black
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def story_type
|
63
|
+
t = @record.story_type
|
64
|
+
case t
|
65
|
+
when 'bug'
|
66
|
+
'🐞'
|
67
|
+
when 'feature'
|
68
|
+
'⭐'
|
69
|
+
when 'release'
|
70
|
+
'🏁'
|
71
|
+
when 'chore'
|
72
|
+
'⚙️'
|
73
|
+
else
|
74
|
+
t
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def estimate
|
79
|
+
@record.estimate.to_i if @record.estimate
|
80
|
+
end
|
81
|
+
|
82
|
+
end
|
83
|
+
end
|
@@ -0,0 +1,107 @@
|
|
1
|
+
# require 'hirb'
|
2
|
+
# require 'hirb-unicode'
|
3
|
+
require 'terminal-table'
|
4
|
+
|
5
|
+
module PT
|
6
|
+
|
7
|
+
class DataTable
|
8
|
+
|
9
|
+
# extend ::Hirb::Console
|
10
|
+
|
11
|
+
def initialize(dataset, title=nil)
|
12
|
+
@title = title
|
13
|
+
@rows = dataset.map{ |row| DataRow.new(row, dataset) }
|
14
|
+
end
|
15
|
+
|
16
|
+
def print(config={})
|
17
|
+
if @rows.empty?
|
18
|
+
puts "\n#{'-- empty list --'.center(36)}\n"
|
19
|
+
else
|
20
|
+
|
21
|
+
headers = [:num]
|
22
|
+
headers += self.class.headers.present? ? self.class.headers : self.class.fields
|
23
|
+
|
24
|
+
fields = [:num] + self.class.fields
|
25
|
+
rows = []
|
26
|
+
@rows.each_with_index do |row, index|
|
27
|
+
_row = fields.map { |f| row.send(f) }
|
28
|
+
rows << _row
|
29
|
+
end
|
30
|
+
table = Terminal::Table.new(title: @title, headings: headers,
|
31
|
+
rows: rows, style: { all_separators: true })
|
32
|
+
puts table
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def [](pos)
|
37
|
+
pos = pos.to_i
|
38
|
+
(pos < 1 || pos > @rows.length) ? nil : @rows[pos-1].record
|
39
|
+
end
|
40
|
+
|
41
|
+
def length
|
42
|
+
@rows.length
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.fields
|
46
|
+
[]
|
47
|
+
end
|
48
|
+
|
49
|
+
def self.headers
|
50
|
+
[]
|
51
|
+
end
|
52
|
+
|
53
|
+
end
|
54
|
+
|
55
|
+
|
56
|
+
class ProjectTable < DataTable
|
57
|
+
|
58
|
+
def self.fields
|
59
|
+
[:name]
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
63
|
+
|
64
|
+
|
65
|
+
class TasksTable < DataTable
|
66
|
+
|
67
|
+
def self.fields
|
68
|
+
[:name, :owners, :story_type, :estimate, :state]
|
69
|
+
end
|
70
|
+
|
71
|
+
def self.headers
|
72
|
+
[:title, :owners, :type, :pt, :state]
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
|
77
|
+
class MultiUserTasksTable < DataTable
|
78
|
+
|
79
|
+
def self.fields
|
80
|
+
[:owned_by, :name, :state, :id]
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
|
85
|
+
class PersonsTable < DataTable
|
86
|
+
|
87
|
+
def self.fields
|
88
|
+
[:initials, :name]
|
89
|
+
end
|
90
|
+
|
91
|
+
end
|
92
|
+
|
93
|
+
class TodoTaskTable < DataTable
|
94
|
+
|
95
|
+
def self.fields
|
96
|
+
[:description]
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
class ActionTable < DataTable
|
101
|
+
|
102
|
+
def self.fields
|
103
|
+
[:action]
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
end
|